Python科普系列——類與方法(下篇)

HansBug發表於2021-11-22

書接上回,繼續來講講關於類及其方法的一些冷知識和燙知識。本篇將重點講講類中的另一個重要元素——方法,也和上篇一樣用各種神奇的例子,從原理和機制的角度為你還原一個不一樣的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 中被引入為欄位。而觀察一下上面的輸出,會發現一個事實—— plusT.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__ 方法,其中 selfaotherb ,返回值為 t
  • __radd__為被加運算,即當執行 t = b + a 時會進入 __radd__ 方法,其中 selfaotherb ,返回值為 t
  • __iadd__為自加法運算,即當執行 a += b 時會進入 __iadd__ 方法,其中 self 為運算前的 aotherb ,返回值為運算後的 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 yyyxxx or yyynot 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.fgetT.x.fsetT.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 類來構造一個只能讀寫不能刪除的屬性?以及如何構造只讀的屬性呢?

延伸思考13property 物件中的 gettersetterdeleter 方法的用途分別是什麼?

歡迎評論區討論!

後續預告

本文重點針對方法的各種機制與特性,從原理角度進行了分析。經過這兩篇關於Python類與方法的科普,基本的概念和機制已經基本講述完畢。在此基礎上,treevalue第三彈也將不久後推出,包含以下主要內容:

  • 樹化方法與類方法,將基於treevalue第二彈中的函式樹化,結合本篇中對方法本質的論述進行講解。
  • 樹化運算,基於算術型別魔術方法的函式樹化,會配合例子進行講解與展示。
  • 基於樹化運算的應用,基於功能性魔術方法的函式樹化,講解之餘集中展示其高度易用性。

此外,歡迎歡迎瞭解OpenDILab的開源專案:

以及我本人的幾個開源專案(部分仍在開發或完善中):

相關文章