書接上回,繼續來講講關於類及其方法的一些冷知識和燙知識。本篇將重點講講類中的另一個重要元素——方法,也和上篇一樣用各種神奇的例子,從原理和機制的角度為你還原一個不一樣的Python。在閱讀本篇之前,推薦閱讀一下上篇的內容:Python科普系列——類與方法(上篇)
物件方法的本質
說到物件導向程式設計,大家應該對方法這一概念並不陌生。其實在上篇中已經提到,在Python中方法的本質就是一個欄位,將一個可執行的物件賦值給當前物件,就可以形成一個方法,並且也嘗試了手動製造一個物件。
但是,如果你對Python有更進一步的瞭解,或者有更加仔細的觀察的話,會發現實際上方法還可以被如下的方式呼叫起來
class T:
def __init__(self, x, y):
self.x = x
self.y = y
def plus(self, z):
return self.x + self.y + z
t = T(2, 5)
t.plus(10) # 17
T.plus(t, 10) # 17, the same as t.plus(10)
沒錯,就是 T.plus(t, 10)
這樣的用法,這在其他一些面嚮物件語言中似乎並沒見到過,看起來有些費解。先別急,我們們再來做另外一個實驗
def plus(self, z):
return self.x + self.y + z
class T:
def __init__(self, x, y):
self.x = x
self.y = y
plus = plus
t = T(2, 5)
print(t)
print(plus)
print(T.plus)
print(t.plus)
# <__main__.T object at 0x7fa58afa7630>
# <function plus at 0x7fa58af95620>
# <function plus at 0x7fa58af95620>
# <bound method plus of <__main__.T object at 0x7fa58afa7630>>
在這個程式中, plus
函式被單獨定義,且在類 T
中被引入為欄位。而觀察一下上面的輸出,會發現一個事實—— plus
和T.plus
完全就是同一個物件,但t.plus
就並不是同一個。根據上篇中的分析,前者是顯而易見的,但是 t.plus
卻成了一個叫做 method
的東西,這又是怎麼回事呢?我們繼續來實驗,接著上一個程式
from types import MethodType
print(type(t.plus), MethodType) # <class 'method'> <class 'method'>
assert isinstance(t.plus, MethodType)
會發現傳說中的 method
原來是 types.MethodType
這個物件。既然已經有了這個線索,那麼我們繼續翻閱一下這個 types.MethodType
的原始碼,原始碼有部分內容不可見,只找到了這些(此處Python版本為 3.9.6
)
class MethodType:
__func__: _StaticFunctionType
__self__: object
__name__: str
__qualname__: str
def __init__(self, func: Callable[..., Any], obj: object) -> None: ...
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
此處很抱歉沒有找到官方文件, types
庫的文件在 MethodType
的部分只有一行概述性文字而沒有實質性內容,所以只好去翻原始碼了,如果有有讀者找到的正經的文件或說明歡迎貼在評論區。不過這麼一看,依然有很關鍵的發現——這個__init__
方法有點東西,從名字和型別來看,func
應該是一個函式,obj
應該是一個任意物件。我們們再來想想,從邏輯要素的角度想想, t.plus
這個東西要想能執行起來,必要因素有那些,答案顯而易見:
- 執行邏輯,通俗來說就是實際執行的函式
plus
- 執行主體,通俗來說在方法前被用點隔開的那個物件
t
到這一步為止答案已經呼之欲出了,不過本著嚴謹的科學精神接下來還是需要進行更進一步的驗證,我們需要嘗試拆解這個 t.plus
,看看裡面到底都有些什麼東西(接上面的程式)
print(set(dir(t.plus)) - set(dir(plus))) # {'__self__', '__func__'}
print(t.plus.__func__) # <function plus at 0x7fa58af95620>
print(t.plus.__self__) # <__main__.T object at 0x7fa58afa7630>
首先第一行,將 dir
結果轉為集合,看看那些欄位是t.plus
擁有而T.plus
沒有的。果不其然,剛好就倆欄位—— __self__
和 __func__
。然後分別將這兩個欄位的值進行輸出,發現—— t.plus.__func__
就是之前定義的那個plus
,而t.plus.__self__
就是例項化出來的t
。
到這一步,與我們的猜想基本吻合,只差一個終極驗證。還記得上篇中那個手動製造出來的物件不,沒錯,讓我們來用MethodType
來更加科學也更加符合實際程式碼行為地再次搭建一回,程式如下
from types import MethodType
class MyObject(object):
pass
if __name__ == '__main__':
t = MyObject() # the same as __new__
t.x = 2 # the same as __init__
t.y = 5
def plus(self, z):
return self.x + self.y + z
t.plus = MethodType(plus, t) # a better implement
print(t.x, t.y) # 2 5
print(t.plus(233)) # 240
print(t.plus)
# <bound method plus of <__main__.MyObject object at 0x7fbbb9170748>>
執行結果和之前一致,也和常規方式實現的物件完全一致,並且這個 t.plus
也正是之前實驗中所看到的那種 method
。至此,Python中物件方法的本質已經十分清楚——物件方法一個基於原有函式,和當前物件,通過types.MethodType
類進行組合後實現的可執行物件。
延伸思考1:基於上述的分析,為什麼 T.plus(t, 10)
會有和 t.plus(10)
等價的執行效果?
延伸思考2:為什麼物件方法開頭第一個引數是 self
,而從第二個引數開始才是實際傳入的? MethodType
物件在被執行的時候,其內部原理可能是什麼樣的?
歡迎評論區討論!
類方法與靜態方法
說完了物件方法,我們們再來看看另外兩種常見方法——類方法和靜態方法。首先是一個最簡單的例子
class T:
def __init__(self, x, y):
self.x = x
self.y = y
def plus(self, z):
return self.x + self.y + z
@classmethod
def method_cls(cls, suffix):
return str(cls.__name__) + suffix
@staticmethod
def method_stt(content):
return ''.join(content[::-1])
其中 method_cls
是一個類方法, method_stt
是一個靜態方法,這一點大家應該並不陌生。那廢話不多說,先看看這個 method_cls
到底是什麼(程式接上文)
print(T.method_cls) # <bound method T.method_cls of <class '__main__.T'>>
t = T(2, 3)
print(t.method_cls) # <bound method T.method_cls of <class '__main__.T'>>
很眼熟對吧,沒錯——無論是位於類T
上的T.method_cls
,還是位於物件t
上的t.method_cls
,都是在上一章節中所探討過的types.MethodType
型別物件,而且還是同一個物件。接下來再看看其內部的結構(程式接上文)
print(T.method_cls.__func__) # <function T.method_cls at 0x7f78d86fe2f0>
print(T.method_cls.__self__) # <class '__main__.T'>
print(T) # <class '__main__.T'>
assert T.method_cls.__self__ is T
其中 __func__
就是這個原版的 method_cls
函式,而 __self__
則是類物件 T
。由此不難發現一個事實——類方法的本質是一個將當前類物件作為主體物件的方法物件。換言之,類方法在本質上和物件方法是同源的,唯一的區別在於這個 self
改叫了 cls
,並且其值換成了當前的類物件。
看完了類方法,接下來是靜態方法。首先和之前一樣,看下 method_stt
的實際內容
print(T.method_stt) # <function method_stt at 0x7fd64fa70620>
t = T(2, 3)
print(t.method_stt) # <function method_stt at 0x7fd64fa70620>
這個結果很出乎意料,但仔細想想也完全合乎邏輯——靜態方法的本質就是一個附著在類和物件上的原生函式。換言之,無論是 T.method_stt
還是 t.method_stt
,實際獲取到的都是原本的那個 method_stt
函式。
延伸思考3:為什麼類方法中的主體被命名為 cls
而不是 self
,有何含義?
延伸思考4:如果將類方法中的 cls
引數重新更名為 self
,是否會影響程式的正常執行?為什麼?
延伸思考5:類方法一種最為常見的應用是搭建工廠函式,例如 T.new_instance
,可用於快速建立不同特點的例項。而在Python中類本身就具備建構函式,因此類工廠方法與建構函式的異同與分工應該是怎樣的呢?請通過對其他語言的類比與實際搭建來談談你的看法。
歡迎評論區討論!
魔術方法的妙用
對於學過C++的讀者們,應該知道有一類特殊的函式是以 operator
開頭的,它們的效果是運算子過載。實際上,在Python中也有類似的特性,比如,讓我們通過一個例子來看看加法運算是如何被過載的
class T:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
print('Operating self + other ...')
if isinstance(other, T):
return T(self.x + other.x, self.y + other.y)
else:
return T(self.x + other, self.y + other)
def __radd__(self, other):
print('Operating other + self ...')
return T(other + self.x, other + self.y)
def __iadd__(self, other):
print('Operating self += other ...')
if isinstance(other, T):
self.x += other.x
self.y += other.y
else:
self.x += other
self.y += other
return self
t1 = T(2, 3)
t2 = T(8, -4)
t3 = t1 + t2
print(t3.x, t3.y)
t4 = t1 + 10
print(t4.x, t4.y)
t5 = -1 + t2
print(t5.x, t5.y)
t1 += 20
print(t1.x, t1.y)
輸出結果如下
Operating self + other ...
10 -1
Operating self + other ...
12 13
Operating other + self ...
7 -5
Operating self += other ...
22 23
對上述例子,可以作一組簡單的解釋:
__add__
為常規的加法運算,即當執行t = a + b
時會進入__add__
方法,其中self
為a
,other
為b
,返回值為t
。__radd__
為被加運算,即當執行t = b + a
時會進入__radd__
方法,其中self
為a
,other
為b
,返回值為t
。__iadd__
為自加法運算,即當執行a += b
時會進入__iadd__
方法,其中self
為運算前的a
,other
為b
,返回值為運算後的a
。
其中,常規的加法運算不難理解,加法自運算也不難理解,但是這個被加運算可能略微難懂。實際上可以結合上述程式碼中的例子 t5 = -1 + t2
來看, -1
作為int
型別物件,並不支援對T
型別物件的常規加法運算,並且Python中也沒有提供類似Ruby那樣過載原生型別的機制,此時如果需要能支援-1 + t2
這樣的加法運算,則需要使用右側主體的__radd__
方法。
在上述例子中提到的三個方法,實際上還有很多的例子,並且這類方法均是以兩個下劃線作為開頭和結尾的,它們有一個共同的名字——魔術方法。魔術方法一個最為直接的應用當然是支援各類算術運算子,我們來看下都支援了哪些算術運算
魔術方法 | 結構示意 | 解釋 | |
---|---|---|---|
add | self + other | 加法 | 常規加法運算 |
radd | other + self | 被加運算 | |
iadd | self += other | 自加運算 | |
sub | self - other | 減法 | 常規減法運算 |
rsub | other - self | 被減運算 | |
isub | self -= other | 自減運算 | |
mul | self * other | 乘法 | 常規乘法運算 |
rmul | other * self | 被乘運算 | |
imul | self *= other | 自乘運算 | |
matmul | self @ other | 矩陣乘法 | 常規矩陣乘法運算 |
rmatmul | other @ self | 矩陣被乘運算 | |
imatmul | self @= other | 矩陣自乘運算 | |
truediv | self / other | 普通除法 | 常規普通除法運算 |
rtruediv | other / self | 普通被除運算 | |
itruediv | self /= other | 普通自除運算 | |
floordiv | self // other | 整除 | 常規整除運算 |
rfloordiv | other // self | 被整除運算 | |
ifloordiv | self //= other | 自整除運算 | |
mod | self % other | 取餘 | 常規取餘運算 |
rmod | other % self | 被取餘運算 | |
imod | self %= other | 自取餘運算 | |
pow | self ** other | 乘方 | 常規乘方運算 |
rpow | other ** self | 被乘方運算 | |
ipow | self **= other | 自乘方運算 | |
and | self & other | 算術與 | 常規算術於運算 |
rand | other & self | 被算術於運算 | |
iand | self &= other | 自算術於運算 | |
or | self | other | 算術或 | 常規算術或運算 |
ror | other | self | 被算術或運算 | |
ior | self |= other | 自算術或運算 | |
xor | self ^ other | 算術異或 | 常規算術異或運算 |
rxor | other ^ self | 被算術異或運算 | |
ixor | self ^= other | 自算術異或運算 | |
lshift | self << other | 算術左移 | 常規算術左移運算 |
rlshift | other << self | 被算術左移運算 | |
ilshift | self <<= other | 自算術左移運算 | |
rshift | self >> other | 算術右移 | 常規算術右移運算 |
rrshift | other >> self | 被算術右移運算 | |
irshift | self >>= other | 自算術右移運算 | |
pos | +self | 取正 | 取正運算 |
neg | -self | 取反 | 取反運算 |
invert | ~self | 算術取反 | 算術取反運算 |
eq | self == other | 大小比較 | 等於比較運算 |
ne | self != other | 不等於比較運算 | |
lt | self < other | 小於比較運算 | |
le | self <= other | 小於或等於比較運算 | |
gt | self > other | 大於比較運算 | |
ge | self >= other | 大於或等於比較運算 |
可以看到,常見的算術運算可謂一應俱全。不過依然有一些東西是沒法通過魔術方法進行過載的,包括但不限於(截止發稿時,Python最新版本為 3.10.0
):
- 三目運算,即
xxx if xxx else xxx
- 邏輯與、邏輯或、邏輯非運算,即
xxx and yyy
和xxx or yyy
和not xxx
除此之外,還有一些比較常見的功能性魔術方法:
魔術方法 | 結構示意 | 解釋 | |
---|---|---|---|
getitem | self[other] | 索引操作 | 索引查詢 |
setitem | self[other] = value | 索引賦值 | |
delitem | del self[other] | 索引刪除 | |
getattr | self.other | 屬性操作 | 屬性獲取 |
setattr | self.other = value | 屬性賦值 | |
delattr | del self.other | 屬性刪除 | |
len | len(self) | 長度 | 獲取長度 |
iter | for x in self: pass | 列舉 | 列舉物件 |
bool | if self: pass | 真偽 | 判定真偽 |
call | self(*args, **kwargs) | 執行 | 執行物件 |
hash | hash(self) | 雜湊 | 獲取雜湊值 |
當然,也有一些功能性的東西是無法被魔術方法所修改的,例如:
- 物件識別符號,即
id(xxx)
如此看來,魔術方法不可謂不神奇,功能還很齊全,只要搭配合理可以起到非常驚豔的效果。那這種方法的本質是什麼呢,其實也很簡單——就是一種包含特殊語義的方法。例如在上述加法運算的例子中,還可以這樣去執行
t1 = T(2, 3)
t2 = T(8, -4)
t3 = t1.__add__(t2)
print(t3.x, t3.y)
# Operating self + other ...
# 10 -1
上面的 t1.__add__(t2)
其實就是 t1 + t2
的真正形態,而Python的物件系統中將這些魔術方法進行了包裝,使之與特殊的語法和用途繫結,便形成了豐富的物件操作模式。
延伸思考6:在算術運算中,常規魔術方法、被動運算魔術方法和自運算魔術方法之間是什麼樣的關係,當存在不止一組可匹配模式時,實際上會執行哪個?請通過實驗嘗試一下。
延伸思考7:為什麼三目運算、邏輯運算無法被魔術方法過載?可能存在什麼樣的技術障礙?以及如果開放過載可能帶來什麼樣的問題?
延伸思考8:為什麼物件識別符號運算無法被魔術方法過載?物件識別符號本質是什麼?如果開放過載可能帶來什麼樣的問題?
延伸思考9:在你用過的Python庫中,有哪些用到了魔術方法對運算子和其他功能進行的過載?具體說說其應用範圍與方式。
延伸思考10:考慮一下numpy和torch等庫中的各類諸如加減乘除的算術運算,其中有矩陣(張量)與矩陣的運算,有矩陣對數值的運算,也有數值對矩陣的運算,它們是如何在Python的語言環境下做到簡單易用的呢?請通過翻閱文件或閱讀原始碼給出你的分析。
延伸思考11: __matmul__
運算在哪些型別物件上可以使其支援 @
運算?在numpy和torch庫中,使用 @
作為運算子對矩陣(張量)進行運算,其運算結果和哪個運算函式是等價的?
歡迎評論區討論!
物件屬性的本質
在Python的類中,還有一種與方法類似但又不同的存在——物件屬性。比如這樣的例子
class T:
def __init__(self, x):
self.__x = x
@property
def x(self):
print('Access x ...')
return self.__x
@x.setter
def x(self, value):
print(f'Set x from {self.__x} to {value} ...')
self.__x = value
@x.deleter
def x(self):
print('Delete x\'s value ...')
self.__x = None
t = T(2)
print(t.x)
t.x = 233
del t.x
# Access x ...
# 2
# Set x from 2 to 233 ...
# Delete x's value ...
通過訪問t.x
會進入第一個getter函式,為t.x
進行賦值會進入第二個setter函式,而如果嘗試刪除t.x
則會進入第三個deleter函式,對於物件 t
來說,這是顯而易見的。不過為了研究一下原理,我們還是看看位於類 T
上的 T.x
的實際內容是什麼(程式碼接上文)
print(T.x) # <property object at 0x7faf16853db8>
可以看到 T.x
是一個屬性(property)物件,緊接著我們們再來看看這裡面所包含的結構
print(set(dir(T.x)) - set(dir(lambda: None)))
print(T.x.fget)
print(T.x.fset)
print(T.x.fdel)
# {'fget', '__delete__', 'deleter', 'fdel', '__set__', '__isabstractmethod__', 'getter', 'setter', 'fset'}
# <function T.x at 0x7f39d32f41e0>
# <function T.x at 0x7f39d32f4268>
# <function T.x at 0x7f39d32f42f0>
可以看到 T.x
比一般的函式物件要多出來的部分,基本上分為get、set和del相關的部分,而其中的T.x.fget
、T.x.fset
和T.x.fdel
則分別指向三個不同的函式。基於目前的這些資訊,尤其是這幾個命名來分析,距離正確答案已經很近了。為了進行證實,我們來嘗試手動製造一個屬性,並將其新增到類上,如下所示
def xget(self):
print('Access x ...')
return self.xvalue
def xset(self, value):
print(f'Set x from {self.xvalue} to {value} ...')
self.xvalue = value
def xdel(self):
print('Delete x\'s value ...')
self.xvalue = None
class T:
def __init__(self, x):
self.xvalue = x
x = property(xget, xset, xdel)
t = T(2)
print(t.x)
t.x = 233
del t.x
# Access x ...
# 2
# Set x from 2 to 233 ...
# Delete x's value ...
由此可見,上述的例子執行完全正常。因此實際上,property物件是一個支援 __get__
、 __set__
、 __delete__
三個魔術方法的特殊物件,關於這三個魔術方法由於涉及到的內容較多,後續可能專門做一期來講講。簡單來說,可以理解為通過在類上進行這樣的一個賦值,使得被例項化的物件的該屬性可以被訪問、賦值和刪除,Python中物件屬性的本質也就是這樣的。
延伸思考12:如何利用 property
類來構造一個只能讀寫不能刪除的屬性?以及如何構造只讀的屬性呢?
延伸思考13: property
物件中的 getter
、 setter
和 deleter
方法的用途分別是什麼?
歡迎評論區討論!
後續預告
本文重點針對方法的各種機制與特性,從原理角度進行了分析。經過這兩篇關於Python類與方法的科普,基本的概念和機制已經基本講述完畢。在此基礎上,treevalue第三彈也將不久後推出,包含以下主要內容:
- 樹化方法與類方法,將基於treevalue第二彈中的函式樹化,結合本篇中對方法本質的論述進行講解。
- 樹化運算,基於算術型別魔術方法的函式樹化,會配合例子進行講解與展示。
- 基於樹化運算的應用,基於功能性魔術方法的函式樹化,講解之餘集中展示其高度易用性。
此外,歡迎歡迎瞭解OpenDILab的開源專案:
以及我本人的幾個開源專案(部分仍在開發或完善中):