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

書接上回,繼續來講講關於類及其方法的一些冷知識和燙知識。本篇將重點講講類中的另一個重要元素——方法,也和上篇一樣用各種神奇的例子,從原理和機制的角度爲你還原一個不一樣的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的開源項目:

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章