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

歡迎來到新的系列,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的開源項目:

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

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