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_instanceFalse當然,這并不是魔法。簡單地說,利用標準的 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 = 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 方法來進行一些標準操作,例如
- 使用 + 運算符將特定數量的 Item 添加到 Grocery 中
- 使用 for 循環直接遍歷 Grocery 類
- 使用括號 [] 符號從 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 secondsdunder 方法是一個簡單而強大的優化技巧,它不僅可以減少冗余計算,還可以通過本地特定實例緩存提供靈活性。
寫在最后
Dunder方法(就是那些用雙下劃線__包裹的特殊方法)在Python中是個很大的話題,而且還在不斷豐富。這篇文章當然沒法面面俱到地講完所有內容。
我寫這些主要是想幫你弄明白兩件事:
- Dunder方法到底是什么?
- 怎么用它們解決實際編程中常見的問題?
說實話,不是每個程序員都必須掌握這些方法。但就我個人經驗來說,當我真正搞懂它們之后,寫代碼的效率提高了很多。相信對你也會很有幫助。
使用Dunder方法最大的好處就是:
- 不用重復造輪子
- 讓代碼更簡潔易讀
- 更符合Python的編程風格




























