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

HansBug發表於2021-11-15

歡迎來到新的系列,up又開新坑了~~

實際上,Python作為一門易用性見長的語言,看上去簡單,卻仍然有很多值得一說的內容,因此這個系列會把Python中比較有意思的地方也給科普一遍。而另一方面,關於Python的學習資料在中文網際網路上已經隨處可見,雖然大都是入門向、實用向的,不過資料覆蓋面也已經挺全乎的了。所以這個系列將會著重去講一些現有中文資料裡不常見到的硬核內容,嘗試去用另外一個視角去講解Python,也因此,這個系列更適合有最基本Python使用基礎,對基本概念有初步認識的讀者。

本文將會著重講講關於類的事情,尤其是類的方法。考慮到treevalue系列的第三篇也即將推出,並且也會較多涉及到關於類和方法相關的內容,因此本文和下篇也會有所側重,主要從原理的角度講解類和方法的本質,以方便理解。而對於略過的部分,後續也將考慮另開文章進行詳細講解。

物件是如何被構造的

首先,讓我們來一塊想一個終極問題——物件是怎麼來的?這看起來答案顯而易見——物件不就是建構函式構造出來的麼?但實際上這麼說並不準確,要說到Python物件是如何被構造的,就不得不說三個特殊的方法: __new____init____del__

首先 __init__ 應該用過Python的都不陌生,但是另外兩個分別是什麼就未必瞭解了。我們來看一個最為直觀的例子

class T:
    def __init__(self, x, y):
        print('Initializing T', x, y)
        self.x = x
        self.y = y

    def __new__(cls, *args, **kwargs):
        print('Creating new T', args, kwargs)
        return object.__new__(cls)

    def __del__(self):
        print('Deleting T')


if __name__ == '__main__':
    t = T(1, 2)
    print('t is initialized.')

# Creating new T (1, 2) {}
# Initializing T 1 2
# t is initialized.
# Deleting T

通過這個例子會發現,執行的順序大致如下圖所示

具體來說,分為以下幾個階段:

  • “從無到有”——通過 __new__ 方法,建立一個新的初始物件,並將此模板物件作為 self 傳入給後續的 __init__ 方法。
  • “組裝配件”——通過 __init__ 方法,基於之前生成的函式初始物件進行裝飾(也就是常說的欄位賦值)。這一過程類似於工廠模式,並非在創造而是在加工。經過了這一步處理的物件,才算是正式完成了物件的初始化,這一初始化完畢的物件也會傳回到呼叫建構函式之處,作為一個真正的例項參與到業務邏輯中
  • “物件銷燬”——當物件的生命週期結束之時,通過 __del__ 方法,處理掉當前物件下於初始化階段組裝的全部“配件”。處理完畢後,該物件將被銷燬,物件的生命週期就此終止

也就是說,我們所日常認知的Python物件,其實是經歷了__new____init__兩個階段構造出來的例項,也正是這樣構造出來的物件,支撐了我們在Python中幾乎所有的資料模型及其業務邏輯。

延伸思考1__new____del__ 分別有什麼樣的應用場景?

延伸思考2:如果需要定義一個類,且需要在任意時刻了解其所有處於活動狀態的例項物件並進行查詢,應該如何去實現?

歡迎評論區討論!

類與物件的本質

首先說到Python中的類,關於類及其方法的基本介紹,可以參考Runoob:Python3 物件導向,裡面有面向初學者的詳細介紹,而對於物件導向的基本程式設計思想,維基百科上也有比較詳細的介紹,此處不作展開。

我們就從類的定義形態開始,講講類的本質是什麼。首先我們來看一個最簡單的類定義

class MyClass:
    cvalue = 233
    
    def __init__(self, x):
        self.__x = x

    def getvalue(self, plus):
        return self.__x + plus
    
    @classmethod
    def getcvalue(cls, plus):
        return cls.cvalue + plus

這就是一種挺典型的類定義了,在進行物件導向程式設計的時候也很常見。除了類之外,我們還都知道,有一種資料型別叫做 dict ,即字典型別,該資料結構可以視為一個基於鍵值對,並支援增刪查改的對映結構,一個典型的例子如下所示

h = {
  'a': 1,
  'b': 'this is a str value',
  'c': ['first', '2nd', 3],
  'd': {
    'content': 'nested dict is okay',
  }
}

你可能會感到奇怪,為什麼我會突然筆鋒一轉,說起了字典型別。那我問你——要是我告訴你,類、物件和字典本質上是差不多的,你會不會感到難以置信呢?首先先說結論——在Python中,類、物件和字典型別,都是典型的對映結構。可以看下如下的這個例子,裡面是一個最為簡單的類,並通過 dir__dict__ 來展示了類與物件的部分內部結構

class T:
    def __init__(self, x, y):
        self.x = x
        self.y = y


if __name__ == '__main__':
    t = T(1, 2)
    print(dir(t))
    print(t.__dict__)
    print(dir(T))
    print(T.__dict__)

# ['__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__', 'x', 'y']
# {'x': 1, 'y': 2}
# ['__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__']
# {'__module__': '__main__', '__init__': <function T.__init__ at 0x7f43dc5f4e18>, '__dict__': <attribute '__dict__' of 'T' objects>, '__weakref__': <attribute '__weakref__' of 'T' objects>, '__doc__': None}

通過 dir 的輸出結果可以看到,無論是類還是物件,內部都包含了大量的欄位名,不僅如此,類和物件的欄位名實際上高度相似,唯二的差異也分別是我們自己定義的欄位 xy ,此處注意是欄位(field)不是屬性(property),雖然一般情況下這兩個概念常常不作區分,但是此處需要消除歧義。因為實際上在Python中,類本質上也是一種物件,名為類物件的物件,如果說上述例子裡物件 t 的型別為 T ,則類物件 T 的型別為 type ,基於這一點我們可以先建立起一個將類和物件統一起來的概念

而在上面的例子中,我們除了執行 dir 函式之外,還訪問了物件的 __dict__ 值。而在物件 t 中,得到的值為 {'x': 1, 'y': 2}回憶一下上一章所述的類的構造方式,再看看類 T__init__ 方法中的實現

class T:
    def __init__(self, x, y):
        print('Initializing T', x, y)
        self.x = x
        self.y = y

把這兩件事放在一起看,有沒有聯想到什麼?沒錯,在這個例子裡__dict__中讀取到的值就是在構造過程 __init__ 中賦的值
不僅如此,我們再看看如果類之間存在繼承關係,會發生什麼,例如下面的例子

class T:
    def __init__(self, x, y):
        self.x = x
        self.y = y


class TP(T):
    def __init__(self, x, y):
        T.__init__(self, x, y)
        self.total = x + y


class TM(TP):
    def __init__(self, x, y):
        TP.__init__(self, x, y)
        self.mul = x * y


if __name__ == '__main__':
    t = TM(3, 5)
    print(t.__dict__)

# {'x': 3, 'y': 5, 'total': 8, 'mul': 15}

可以看到幾級父類上 __init__ 賦的值都在 __dict__ 中。這一現象如果結合前一章對 __init__ 原理的解釋,則成因是顯而易見的——建構函式__init__的本質是一個工廠函式,從這個角度來看,則 TM.__init__ 也是一個工廠函式,而其內部直接或間接呼叫了 TP.__init__T.__init__ 這兩個屬於父類的工廠函式,因此可以將內部的裝飾效果一併應用於當前物件中,形成類似類繼承的效果

延伸思考3:如果對已經構造完畢的物件的某未定義的屬性進行直接賦值(例如 t.undefined = 10 ),會發生什麼現象?

延伸思考4:如何解釋上面的現象?與建構函式中的屬性賦值有何異同?

延伸思考5:類似的,如果將 t 賦值為 object() ,執行延伸思考3中的賦值操作,會發生什麼現象?如何解釋這一現象?(可以參考官方文件

歡迎評論區討論!

如何手動製造一個物件

基於以上的分析,對類和物件的本質已經初見端倪——類和物件本質上也是一種對映結構,這一結構中存值的那一部分位於 __dict__ ,而儲存業務邏輯的部分則是各個函式,它們在 dir(t) 中均可以找到名稱,並且可以通過 getattr 進行訪問(實際上在Python中,函式也同樣是一個物件)。

因此,我們可以基於上述的原理,嘗試構造一個簡易的物件出來。例如下面的例子

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(z):
        return t.x + t.y + z


    t.plus = plus  # the same as function def

    print(t.x, t.y)
    print(t.plus(233))

首先在第6行,我們模仿 __new__ 方法的思路,手動建立一個空物件(注意不能直接用 object ,而需要繼承一層,具體原因詳見[官方文件中的Note部分](https://{'x': 3, 'y': 5, 'total': 8, 'mul': 15}));接下來分別對物件的屬性進行賦值,包括數值 xy ,以及一個會基於 t.xt.y 進行運算處理的函式 plus (一般我們更習慣於稱之為方法);最後就是使用這一手動建立的物件,可以看到 t.xt.y均可正常使用,並且方法t.plus(z)也可以被正常呼叫。經過這一系列操作,一個手工建立的物件就產生了,而且從使用者的角度來看,也和正常例項化的物件並無差異

如何手動製造一個類

不僅物件,類也是可以手動製造出來的。話不多說,我們先看看來自官方文件的構造 type 類說明

class type(object)
class type(name, bases, dict, **kwds)
With one argument, return the type of an object. The return value is a type object and generally the same object as returned by object.class.
The isinstance() built-in function is recommended for testing the type of an object, because it takes subclasses into account.
With three arguments, return a new type object. This is essentially a dynamic form of the class statement. The name string is the class name and becomes the name attribute. The bases tuple contains the base classes and becomes the bases attribute; if empty, object, the ultimate base of all classes, is added. The dict dictionary contains attribute and method definitions for the class body; it may be copied or wrapped before becoming the dict attribute. The following two statements create identical type objects:

看起來挺長,不過後續附了一個最為簡明扼要的例子

# first code
class X:
    a = 1

# second code, the same as the former one
X = type('X', (), dict(a=1))

所以其實依然不難理解,簡單來說就是三個基本引數:

  • 名稱( name )——字面意思,表示構造的類名
  • 基類( bases )——字面意思,表示所需要繼承的基類
  • 字典( dict )——即需要賦予物件的屬性

因此基於以上的原理,我們可以構造出來一個自己的類,就像這樣

def __init__(self, x, y):
    self.x = x
    self.y = y


def plus(self, z):
    return self.x + self.y + z


XYTuple = type('XYTuple', (), dict(
    __init__=__init__,
    plus=plus,
))

if __name__ == '__main__':
    t = XYTuple(2, 5)
    print(t.x, t.y)
    print(t.plus(233))

# 2 5
# 240

# The definition of class is exactly the same as :
# class XYTuple:
#     def __init__(self, x, y):
#         self.x = x
#         self.y = y
# 
#     def plus(self, z):
#         return self.x + self.y + z

不難發現,從這樣的視角來看,一個類的裝配也大致分為三步:

  • “初始化階段”——此階段會建立一個指定名稱的類物件
  • “繼承階段”——此階段會嘗試在類物件上建立與已有類的繼承關係。
  • “裝配階段”——次階段會將類所需的各個屬性,裝配至類物件上。

至此,經過了三個階段後,一個類物件建立完畢,並且在使用上和正常定義的類並無差別。

延伸思考6collections 庫中的 namedtuple 函式是如何構造一個類出來的?可以閱讀一下原始碼進行分析。

歡迎評論區討論!

私有欄位的本質

對於瞭解Python物件導向或學習過Java、C++等其他語言的讀者,應該對私有欄位這個東西並不陌生(如果還不夠了解的話可以看看Python3 物件導向 - 類的私有屬性)。在Python中,我們所熟知的私有欄位大致是如下的形態

class T:
    def __init__(self):
        self.__private = 1   # private field, starts with __
        self._protected = 2  # protected field, starts with _
        self.public = 3      # public field, starts with alphabets

簡單來說就是:

  • 私有欄位,僅可以被類內部訪問,以雙下劃線開頭
  • 保護欄位,可以被當前類及其子類訪問,以單下劃線開頭
  • 公有欄位,可以被自由訪問,以字元開頭

因此對上面的例子中,實際訪問效果如下

t = T()
t.__private   # Attribute Error!
t._protected  # 2
t.public      # 3

保護欄位和公有欄位是可以被訪問到的,但是一般情況下保護欄位並不推薦在當前類或子類以外的地方進行訪問(實際上當你這麼做的時候,不少IDE都會報出明確的warning),而私有欄位則無法訪問,直接訪問會導致報錯。
看起來似乎一切很正常,但是讓我們來看看上面例子中變數 t 內部都有什麼

t.__dict__  # {'_T__private': 1, '_protected': 2, 'public': 3}

其中 public_protected 是意料之內的,但是除此之外還包含一個_T__private,並且其值正是在建構函式中所賦予的值。基於這一點,我們再來做個實驗

t._T__private  # 1

發現私有欄位居然也可以被訪問。至此,我們可以得出一個結論——在Python中,並不存在嚴格意義上的私有欄位,我們所知道的私有欄位本質上更像一種語法糖效果,而保護欄位則乾脆是被擺在明面上的。
從這個角度來看不難發現,在Python中這些欄位之所以還能起到私有欄位或保護欄位應有的效果,本質上靠的是開發者意義上的約束,而非語言系統本身的強制力。這一點和Java等靜態語言存在本質上的差異,在Java中定義的私有欄位一般無法通過正常途徑進行訪問,即便通過反射機制強制讀取,也需要繞開一系列機制。

延伸思考7:類似Python的私有欄位處理方式還在哪些語言中有所體驗?類似Java的呢?

延伸思考8:以上的兩種處理方式分別體現了什麼樣的思維方式?有何優劣?分別適合什麼樣的開發者與應用場景?

歡迎評論區討論!

後續預告

本文重點針對類的特性,從原理角度進行了分析。在本系列的下一篇中,會重點針對類的方法和屬性進行講解,以及treevalue第三彈也將會在不久後推出,敬請期待。

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

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

相關文章