Python 隱藏法寶:雙下劃線 _ _Dunder_ _
你可能不知道,Python里那些用雙下劃線包裹的"魔法方法"(Dunder方法),其實是提升代碼質(zhì)量的絕佳工具。但有趣的是,很多經(jīng)驗豐富的開發(fā)者對這些方法也只是一知半解。
先說句公道話: 這其實情有可原。因為在多數(shù)情況下,Dunder方法的作用是"錦上添花"——它們能讓代碼更簡潔規(guī)范,但不用它們也能完成任務(wù)。有時候我們甚至不知不覺就在使用這些特殊方法了。
如果你符合以下任一情況:
- 經(jīng)常用Python但不太了解這個特性
- 像我一樣癡迷編程語言的精妙設(shè)計
- 想讓代碼既專業(yè)又優(yōu)雅
那么,這篇文章就是為你準備的!我們將探索如何巧妙運用這些"魔法方法"來:
- 大幅簡化代碼邏輯
- 提升代碼可讀性
- 寫出更Pythonic的優(yōu)雅代碼
表象會騙人......即使在 Python 中也是如此!
如果說我在生活中學到了什么,那就是并非所有東西都像第一眼看上去那樣,Python 也不例外。
看一個看似簡單的例子:
class EmptyClass:
pass
這是我們可以在 Python 中定義的最 “空” 的自定義類,因為我們沒有定義屬性或方法。它是如此的空,你會認為你什么也做不了。
然而,事實并非如此。例如,如果您嘗試創(chuàng)建該類的實例,甚至比較兩個實例是否相等,Python 都不會抱怨:
empty_instance = EmptyClass()
another_empty_instance = EmptyClass()
empty_instance == another_empty_instance
False
當然,這并不是魔法。簡單地說,利用標準的 object 接口,Python 中的任何對象都繼承了一些默認屬性和方法,這些屬性和方法可以讓用戶與之進行最少的交互。
雖然這些方法看起來是隱藏的,但它們并不是不可見的。要訪問可用的方法,包括 Python 自己分配的方法,只需使用 dir()
內(nèi)置函數(shù)。對于我們的空類,我們得到
>>> 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__
方法,我們就不應(yīng)該對我們可以實例化一個該類的對象感到驚訝。
Dunder方法
最后輸出中顯示的所有方法都屬于一個特殊的群體--猜猜看--dunder 方法。dunder 是雙下劃線(double underscore)的縮寫,指的是這些方法名稱開頭和結(jié)尾的雙下劃線。
它們之所以特殊,有以下幾個原因:
- 它們內(nèi)置于每個對象中:每個 Python 對象都配備了由其類型決定的一組特定的 dunder 方法。
- 它們是隱式調(diào)用的:許多 dunder 方法是通過與 Python 本機運算符或內(nèi)置函數(shù)的交互自動觸發(fā)的。例如,用
==
比較兩個對象相當于調(diào)用它們的__eq__
方法。 - 它們是可定制的:您可以覆蓋現(xiàn)有的 dunder 方法,或者為您的類定義新的方法,以便在保留隱式調(diào)用的同時賦予它們自定義的行為。
對于大多數(shù) Python 開發(fā)者來說,他們遇到的第一個 dunder 是 __init__
,構(gòu)造函數(shù)方法。當您創(chuàng)建一個類的實例時,這個方法會被自動調(diào)用,使用熟悉的語法 MyClass(*args, **kwargs)
作為顯式調(diào)用 MyClass.__init__(*args, **kwargs)
的快捷方式。
盡管是最常用的方法,__init__
也是最專業(yè)的 dunder 方法之一。它沒有充分展示 dunder 方法的靈活性和強大功能,而這些方法可以讓您重新定義對象與原生 Python 特性的交互方式。
使對象漂亮
定義一個類來表示商店中出售的物品,并通過指定名稱和價格來創(chuàng)建一個實例。
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 變量的內(nèi)容,會發(fā)生什么?現(xiàn)在,Python 所能做的就是告訴我們它是什么類型的對象,以及它在內(nèi)存中的分配位置:
item
<__main__.Item at 0x00000226C614E870>
試著得到一個信息量更大、更漂亮的輸出!
要做到這一點,我們可以覆蓋 __repr__
dunder,當在交互式 Python 控制臺中鍵入一個類實例時,它的輸出將完全是打印出來的,而且--只要沒有覆蓋另一個 dunder 方法 __str__
--當試圖調(diào)用 print() 時也是如此。
注意:通常的做法是讓 __repr__
提供重新創(chuàng)建打印實例所需的語法。因此,在后一種情況下,我們希望輸出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)
沒什么特別的吧?你說得沒錯:我們本可以實現(xiàn)同樣的方法,并將其命名為 *my_custom_repr
*,而不需要使用indo dunder 方法。然而,雖然任何人都能立即理解 print(item) 或 item 的意思,但 item.my_custom_repr()
這樣的方法也能理解嗎?
定義對象與 Python 本地運算符之間的交互
假設(shè)我們想創(chuàng)建一個新類,即 Grocery,它允許我們建立一個 Item 及其數(shù)量的集合。
在這種情況下,我們可以使用 dunder 方法來進行一些標準操作,例如
- 使用 + 運算符將特定數(shù)量的 Item 添加到 Grocery 中
- 使用 for 循環(huán)直接遍歷 Grocery 類
- 使用括號 [] 符號從 Grocery 類中訪問特定的 Item
為了實現(xiàn)這一目標,我們將定義(我們已經(jīng)看到泛型類默認情況下沒有這些方法)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. 的內(nèi)容。
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__
方法,我們可以按照該方法中實現(xiàn)的邏輯對一個 Grocery 對象進行循環(huán)(即,隱式循環(huán)將遍歷可遍歷屬性 items 中包含的元素)。
print([item for item in grocery])
[Item('Milk (1L)', 0.99), Item('Soy Sauce (0.375L)', 1.99)]
同樣,訪問元素也是通過定義 __getitem__
函數(shù)來處理的:
>>> 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"
從本質(zhì)上講,我們?yōu)?Grocery 類分配了一些類似字典的標準行為,同時也允許進行一些該數(shù)據(jù)類型本機無法進行的操作。
增強功能:使類可調(diào)用,以實現(xiàn)簡單性和強大功能。
最后,讓我們用一個示例來結(jié)束對 dunder 方法的深入探討,展示它們?nèi)绾纬蔀槲覀兊膹姶蠊ぞ摺?/span>
想象一下,我們實現(xiàn)了一個函數(shù),它可以根據(jù)特定輸入執(zhí)行確定性的慢速計算。為了簡單起見,我們將以一個內(nèi)置 time.sleep 為幾秒的標識函數(shù)為例。
import time
def expensive_function(input):
time.sleep(5)
return input
如果我們對同一輸入運行兩次函數(shù),會發(fā)生什么情況?那么,現(xiàn)在計算將被執(zhí)行兩次,這意味著我們將兩次獲得相同的輸出,在整個執(zhí)行時間內(nèi)等待兩次(即總共 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
這合理嗎?為什么我們要對相同的輸入進行相同的計算(導(dǎo)致相同的輸出),尤其是在計算過程很慢的情況下?
一種可能的解決方案是將該函數(shù)的執(zhí)行 “封裝 ”在類的 __call__
dunder 方法中。
這使得類的實例可以像函數(shù)一樣被調(diào)用--這意味著我們可以使用簡單的語法 my_class_instance(\*args,\**kwargs)
--同時也允許我們使用屬性作為緩存來減少計算時間。
通過這種方法,我們還可以靈活地創(chuàng)建多個進程(即類實例),每個進程都有自己的本地緩存。
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
不出所料,函數(shù)在第一次運行后會被緩存起來,這樣就不需要進行第二次計算,從而將總時間縮短了一半。
如上所述,如果需要,我們甚至可以創(chuàng)建該類的獨立實例,每個實例都有自己的緩存。
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 方法是一個簡單而強大的優(yōu)化技巧,它不僅可以減少冗余計算,還可以通過本地特定實例緩存提供靈活性。
寫在最后
Dunder方法(就是那些用雙下劃線__包裹的特殊方法)在Python中是個很大的話題,而且還在不斷豐富。這篇文章當然沒法面面俱到地講完所有內(nèi)容。
我寫這些主要是想幫你弄明白兩件事:
- Dunder方法到底是什么?
- 怎么用它們解決實際編程中常見的問題?
說實話,不是每個程序員都必須掌握這些方法。但就我個人經(jīng)驗來說,當我真正搞懂它們之后,寫代碼的效率提高了很多。相信對你也會很有幫助。
使用Dunder方法最大的好處就是:
- 不用重復(fù)造輪子
- 讓代碼更簡潔易讀
- 更符合Python的編程風格