Python 隐藏法宝:双下划线 _ _Dunder_ _
你可能不知道,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)的缩写,指的是这些方法名称开头和结尾的双下划线。
它们之所以特殊,有以下几个原因:
-
它们内置于每个对象中:每个 Python 对象都配备了由其类型决定的一组特定的 dunder 方法。
-
它们是隐式调用的:许多 dunder 方法是通过与 Python 本机运算符或内置函数的交互自动触发的。例如,用
==
比较两个对象相当于调用它们的__eq__
方法。 -
它们是可定制的:您可以覆盖现有的 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 = nameself.price = priceitem = 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 = nameself.price = pricedef __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 方法来进行一些标准操作,例如
-
使用 + 运算符将特定数量的 Item 添加到 Grocery 中
-
使用 for 循环直接遍历 Grocery 类
-
使用括号 [] 符号从 Grocery 类中访问特定的 Item
为了实现这一目标,我们将定义(我们已经看到泛型类默认情况下没有这些方法)dunder 方法 __add__
, __iter__
和__getitem__
。
from typing import Optional, Iterator
from typing_extensions import Selfclass 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] += quantityelse:new_grocery.items[new_item] = quantityreturn new_grocerydef __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]
1fake_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] = outputreturn outputelse: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中是个很大的话题,而且还在不断丰富。这篇文章当然没法面面俱到地讲完所有内容。
我写这些主要是想帮你弄明白两件事:
-
Dunder方法到底是什么?
-
怎么用它们解决实际编程中常见的问题?
说实话,不是每个程序员都必须掌握这些方法。但就我个人经验来说,当我真正搞懂它们之后,写代码的效率提高了很多。相信对你也会很有帮助。
使用Dunder方法最大的好处就是:
-
不用重复造轮子
-
让代码更简洁易读
-
更符合Python的编程风格
这些优点,对你一定是有用的。对吧?点个赞吧❤️支持一下