Python 隐藏法宝:双下划线 _ _Dunder_ _

发布于:2025-06-05 ⋅ 阅读:(21) ⋅ 点赞:(0)

你可能不知道,Python里那些用双下划线包裹的"魔法方法"(Dunder方法),其实是提升代码质量的绝佳工具。但有趣的是,很多经验丰富的开发者对这些方法也只是一知半解。

先说句公道话: 这其实情有可原。因为在多数情况下,Dunder方法的作用是"锦上添花"——它们能让代码更简洁规范,但不用它们也能完成任务。有时候我们甚至不知不觉就在使用这些特殊方法了。

如果你符合以下任一情况:

  • 经常用Python但不太了解这个特性

  • 像我一样痴迷编程语言的精妙设计

  • 想让代码既专业又优雅

那么,这篇文章就是为你准备的!我们将探索如何巧妙运用这些"魔法方法"来:

  • 大幅简化代码逻辑

  • 提升代码可读性

  • 写出更Pythonic的优雅代码

表象会骗人......即使在 Python 中也是如此!

如果说我在生活中学到了什么,那就是并非所有东西都像第一眼看上去那样,Python 也不例外。

看一个看似简单的例子:

class EmptyClass:
  pass

这是我们可以在 Python 中定义的最 “空” 的自定义类,因为我们没有定义属性或方法。它是如此的空,你会认为你什么也做不了。

然而,事实并非如此。例如,如果您尝试创建该类的实例,甚至比较两个实例是否相等,Python 都不会抱怨:

empty_instance = EmptyClass()
another_empty_instance = EmptyClass()
empty_instance == another_empty_instance
False

当然,这并不是魔法。简单地说,利用标准的 object 接口,Python 中的任何对象都继承了一些默认属性和方法,这些属性和方法可以让用户与之进行最少的交互。

虽然这些方法看起来是隐藏的,但它们并不是不可见的。要访问可用的方法,包括 Python 自己分配的方法,只需使用 dir() 内置函数。对于我们的空类,我们得到

>>> dir(EmptyClass)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', 
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', 
'__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', 
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', 
'__str__', '__subclasshook__', '__weakref__']

正是这些方法可以解释我们之前观察到的行为。例如,由于该类实际上有一个__init__方法,我们就不应该对我们可以实例化一个该类的对象感到惊讶。

Dunder方法

最后输出中显示的所有方法都属于一个特殊的群体--猜猜看--dunder 方法。dunder 是双下划线(double underscore)的缩写,指的是这些方法名称开头和结尾的双下划线。

它们之所以特殊,有以下几个原因:

  1. 它们内置于每个对象中:每个 Python 对象都配备了由其类型决定的一组特定的 dunder 方法。

  2. 它们是隐式调用的:许多 dunder 方法是通过与 Python 本机运算符或内置函数的交互自动触发的。例如,用 == 比较两个对象相当于调用它们的 __eq__方法。

  3. 它们是可定制的:您可以覆盖现有的 dunder 方法,或者为您的类定义新的方法,以便在保留隐式调用的同时赋予它们自定义的行为。

对于大多数 Python 开发者来说,他们遇到的第一个 dunder 是 __init__,构造函数方法。当您创建一个类的实例时,这个方法会被自动调用,使用熟悉的语法 MyClass(*args, **kwargs)作为显式调用 MyClass.__init__(*args, **kwargs) 的快捷方式。

尽管是最常用的方法,__init__ 也是最专业的 dunder 方法之一。它没有充分展示 dunder 方法的灵活性和强大功能,而这些方法可以让您重新定义对象与原生 Python 特性的交互方式。

使对象漂亮

定义一个类来表示商店中出售的物品,并通过指定名称和价格来创建一个实例。

class Item:
    def __init__(self, name: str, price: float) -> None:
        self.name = name
        self.price = price


item = Item(name="Milk (1L)", price=0.99)

如果我们尝试显示 item 变量的内容,会发生什么?现在,Python 所能做的就是告诉我们它是什么类型的对象,以及它在内存中的分配位置:

item
<__main__.Item at 0x00000226C614E870>

试着得到一个信息量更大、更漂亮的输出!

要做到这一点,我们可以覆盖 __repr__ dunder,当在交互式 Python 控制台中键入一个类实例时,它的输出将完全是打印出来的,而且--只要没有覆盖另一个 dunder 方法 __str__ --当试图调用 print() 时也是如此。

注意:通常的做法是让 __repr__ 提供重新创建打印实例所需的语法。因此,在后一种情况下,我们希望输出Item(name="Milk(1L)", price=0.99)

class Item:
    def __init__(self, name: str, price: float) -> None:
        self.name = name
        self.price = price

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}('{self.name}', {self.price})"


item = Item(name="Milk (1L)", price=0.99)

item # In this example it is equivalent also to the command: print(item)
Item('Milk (1L)', 0.99)

没什么特别的吧?你说得没错:我们本可以实现同样的方法,并将其命名为 *my_custom_repr*,而不需要使用indo dunder 方法。然而,虽然任何人都能立即理解 print(item) 或 item 的意思,但 item.my_custom_repr() 这样的方法也能理解吗?

定义对象与 Python 本地运算符之间的交互

假设我们想创建一个新类,即 Grocery,它允许我们建立一个 Item 及其数量的集合。

在这种情况下,我们可以使用 dunder 方法来进行一些标准操作,例如

  1. 使用 + 运算符将特定数量的 Item 添加到 Grocery 中

  2. 使用 for 循环直接遍历 Grocery 类

  3. 使用括号 [] 符号从 Grocery 类中访问特定的 Item

为了实现这一目标,我们将定义(我们已经看到泛型类默认情况下没有这些方法)dunder 方法 __add____iter__ 和__getitem__

from typing import Optional, Iterator
from typing_extensions import Self


class Grocery:

    def __init__(self, items: Optional[dict[Item, int]] = None):
        self.items = items or dict()

    def __add__(self, new_items: dict[Item, int]) -> Self:

        new_grocery = Grocery(items=self.items)

        for new_item, quantity in new_items.items():

            if new_item in new_grocery.items:
                new_grocery.items[new_item] += quantity
            else:
                new_grocery.items[new_item] = quantity

        return new_grocery

    def __iter__(self) -> Iterator[Item]:
        return iter(self.items)

    def __getitem__(self, item: Item) -> int:

        if self.items.get(item):
            return self.items.get(item)
        else:
            raise KeyError(f"Item {item} not in the grocery")

初始化一个 Grocery 实例,并打印其主要属性 items. 的内容。

item = Item(name="Milk (1L)", price=0.99)
grocery = Grocery(items={item: 3})

print(grocery.items)
{Item('Milk (1L)', 0.99): 3}

然后,我们使用 + 运算符添加一个新项目,并验证更改是否已生效。

new_item = Item(name="Soy Sauce (0.375L)", price=1.99)
grocery = grocery + {new_item: 1} + {item: 2}

print(grocery.items)
{Item('Milk (1L)', 0.99): 5, Item('Soy Sauce (0.375L)', 1.99): 1}

既友好又明确,对吗?

通过 __iter__ 方法,我们可以按照该方法中实现的逻辑对一个 Grocery 对象进行循环(即,隐式循环将遍历可遍历属性 items 中包含的元素)。

print([item for item in grocery])
[Item('Milk (1L)', 0.99), Item('Soy Sauce (0.375L)', 1.99)]

同样,访问元素也是通过定义 __getitem__ 函数来处理的:

>>> grocery[new_item]
1

fake_item = Item("Creamy Cheese (500g)", 2.99)
>>> grocery[fake_item]
KeyError: "Item Item('Creamy Cheese (500g)', 2.99) not in the grocery"

从本质上讲,我们为 Grocery 类分配了一些类似字典的标准行为,同时也允许进行一些该数据类型本机无法进行的操作。

增强功能:使类可调用,以实现简单性和强大功能。

最后,让我们用一个示例来结束对 dunder 方法的深入探讨,展示它们如何成为我们的强大工具。

想象一下,我们实现了一个函数,它可以根据特定输入执行确定性的慢速计算。为了简单起见,我们将以一个内置 time.sleep 为几秒的标识函数为例。

import time 

def expensive_function(input):
    time.sleep(5)
    return input

如果我们对同一输入运行两次函数,会发生什么情况?那么,现在计算将被执行两次,这意味着我们将两次获得相同的输出,在整个执行时间内等待两次(即总共 10 秒)。

start_time = time.time()

>>> print(expensive_function(2))
>>> print(expensive_function(2))
>>> print(f"Time for computation: {round(time.time()-start_time, 1)} seconds")
2
2
Time for computation: 10.0 seconds

这合理吗?为什么我们要对相同的输入进行相同的计算(导致相同的输出),尤其是在计算过程很慢的情况下?

一种可能的解决方案是将该函数的执行 “封装 ”在类的 __call__ dunder 方法中。

这使得类的实例可以像函数一样被调用--这意味着我们可以使用简单的语法 my_class_instance(\*args,\**kwargs) --同时也允许我们使用属性作为缓存来减少计算时间。

通过这种方法,我们还可以灵活地创建多个进程(即类实例),每个进程都有自己的本地缓存。

class CachedExpensiveFunction:

    def __init__(self) -> None:
        self.cache = dict()

    def __call__(self, input):
        if input not in self.cache:
            output = expensive_function(input=input)
            self.cache[input] = output
            return output
        else:
            return self.cache.get(input)


start_time = time.time()
cached_exp_func = CachedExpensiveFunction()

>>> print(cached_exp_func(2))
>>> print(cached_exp_func(2))
>>> print(f"Time for computation: {round(time.time()-start_time, 1)} seconds")
2
2
Time for computation: 5.0 seconds

不出所料,函数在第一次运行后会被缓存起来,这样就不需要进行第二次计算,从而将总时间缩短了一半。

如上所述,如果需要,我们甚至可以创建该类的独立实例,每个实例都有自己的缓存。

start_time = time.time()
another_cached_exp_func = CachedExpensiveFunction()

>>> print(cached_exp_func(3))
>>> print(another_cached_exp_func (3))
>>> print(f"Time for computation: {round(time.time()-start_time, 1)} seconds")
3
3
Time for computation: 10.0 seconds

dunder 方法是一个简单而强大的优化技巧,它不仅可以减少冗余计算,还可以通过本地特定实例缓存提供灵活性。

写在最后

Dunder方法(就是那些用双下划线__包裹的特殊方法)在Python中是个很大的话题,而且还在不断丰富。这篇文章当然没法面面俱到地讲完所有内容。

我写这些主要是想帮你弄明白两件事:

  1. Dunder方法到底是什么?

  2. 怎么用它们解决实际编程中常见的问题?

说实话,不是每个程序员都必须掌握这些方法。但就我个人经验来说,当我真正搞懂它们之后,写代码的效率提高了很多。相信对你也会很有帮助。

使用Dunder方法最大的好处就是:

  • 不用重复造轮子

  • 让代码更简洁易读

  • 更符合Python的编程风格

这些优点,对你一定是有用的。对吧?点个赞吧❤️支持一下


网站公告

今日签到

点亮在社区的每一天
去签到