python物件導向

roc_guo發表於2022-12-15

物件導向是一種程式設計正規化。正規化,可以認為是一組方法論,程式設計正規化是一組如何組織程式碼的方法論。主流的程式設計正規化有:PP、IP、FP、LP、OOP、AOP。

  • PP:程式導向,代表是 C 語言;
  • IP:面向指令,代表是彙編;
  • FP:函數語言程式設計,把世界抽象成一個個的函式,它要求的是無副作用,即相同的輸入會產生相同的輸出。因此它是一種無副作用的過程;
  • LP:面向邏輯的程式設計,把世界抽象成與或非,代表是 prolog;
  • AOP:面向方面,它用來解決一類問題,Python 中的裝飾器就是 AOP 的思想。代表是 SQL 語句;

OOP 就是今天的主角,它的世界觀為:

  • 世界由物件組成;
  • 物件具有運動規律和內部狀態;
  • 物件之間可以相互作用。

以目前人類的認知來說,OOP 是最接近真實世界的程式設計正規化。

設計大型軟體時,物件導向比程式導向更容易實現。程式由指令加資料組成,程式碼可以選擇以指令為核心或以資料為核心進行編寫:

  • 以指令為核心:圍繞“正在發生什麼”進行編寫。這是程式導向程式設計,程式具有一系列線性步驟,主體思想是程式碼作用於資料;
  • 以資料為核心:圍繞“將影響誰”進行編寫。這就是物件導向程式設計(OOP),圍繞資料及為資料嚴格定義的介面來組織程式,用資料控制對程式碼的訪問。

類和物件是物件導向中的兩個重要概念。

  • 類:是對事物的抽象,如人類、球類等。類有靜態屬性和靜態方法,類無法訪問動態屬性和動態方法;
  • 物件:是類的一個例項,如足球、籃球。物件有動態屬性和動態方法,物件可以訪問靜態屬性和靜態方法;

物件導向的特性:

  1. 唯一性:物件是唯一的,不存在兩個相同的物件,除非他們是同一個物件。就像我作為一個物件,世界上只有一個我;
  2. 分類性:物件是可分類的,比如動物、食物。

例項說明:球類可以對球的特徵和行為進行抽象,然後可以例項化一個真實的球實體出來。比如我們對人類進行例項化,可以例項化出張三、李四、王五等。

所有程式語言的最終目的都是提供一種抽象方法。在機器模型("解空間"或“方案空間”)與實際解決的問題模型(“問題空間”)之間,程式設計師必須建立一種聯絡。物件導向是將問題空間中的元素以及它們在解空間中的表示物抽象為物件,並允許通過問題來描述問題而不是方案。可以把例項想象成一種新型變數,它儲存著資料,但可以對自身的資料執行操作。

型別由狀態集合(資料)和轉換這些狀態的操作集合組成。

類是抽象的概念,例項才是具體的。但是要先設計類,才能完成例項化。類是定一個多個同一型別物件共享的結構和行為(資料和程式碼)。就像 list 就是一種型別,使用 list. 然後就可tab補全一堆方法,但是我們使用 list.pop() 是會報錯的,因為它是一個抽象的概念。

類內部包含資料和方法這兩個核心,二者都是類成員。其中資料被稱為成員變數或例項變數;而方法被稱為成員方法,它被用來操縱類中的程式碼,用於定義如何使用這些成員變數。因此一個類的行為和介面是通過方法來定義的。

方法和變數就是資料和程式碼,如果是私有的變數和方法,只能夠在例項內部使用。如果是公共的,可以在例項外部呼叫。

在面對物件的程式設計中,所有的東西都是物件,我們儘可能把所有的東西都設計為物件。程式本身也就是一堆物件的集合,如果要有物件,事先要有類,如果沒有類,使用者就要自定義類,所以使用者自己寫的類就成為自定義型別。也就是說如果程式裡面有類,那我們就可以直接建立例項,如果沒有那我們就要建立,然後例項化。程式的執行過程就是這些物件彼此之間互相操作的過程,通過訊息傳遞,各物件知道自己該做什麼。如果傳遞訊息?每個物件都有呼叫介面,也就是方法,我們向方法傳遞一個引數就表示我們呼叫了該物件的方法,通過引數傳遞訊息。從這個角度來講,訊息就是呼叫請求。

每個物件都有自己的儲存空間,並可容納其他物件。比如我們定義列表l1,裡面有三個元素,那l1是物件,三個元素也是物件。通過封裝現有物件,我們可以製作新型物件。每個物件都屬於某一個型別,型別即為類,物件是類的例項。類的一個重要特性為“能發什麼樣的訊息給它”,同一個類的所有物件都能接受相同的訊息。類的訊息介面就是它提供的方法,我們使用l1.pop(),就相當於給類傳送了訊息。而不同的類訊息的介面並不相同,就像我們不能對字串型別使用pop方法一樣。

定義一個類後,可以根據需要例項化出多個物件,如何利用物件完成真正有用的工作?必須有一種辦法能向物件發出請求,令其做一些事情。這就是所謂的方法,這些方法加起來就表現為該類的介面。因此每個物件僅能接受特定的請求,物件的“型別”或“類”規定了它的介面型別。

資料儲存在變數中,變數就是所謂的屬性,方法就是函式。

類間的關係:

  • 依賴("uses-a"):一個類的方法操縱另一個類的物件;
  • 聚合("has-a"):類 A 的物件包含類 B 的物件;
  • 繼承("is-a"):描述特殊與一般關係。

面對物件的特徵:

  • 封裝(Encapsulation):隱藏實現方案細節,並將程式碼及其處理的資料繫結在一起的一種程式設計機制,用於保證程式和資料不受外部干擾且不會被誤用。類把需要的變數和函式組合在一起,這種包含稱為封裝。比如 list.pop() 實現的細節我們並不知道,這就是一種封裝;
  • 繼承(Inheritance):一個物件獲得另一個物件屬性的過程,用於實現按層分類的概念。一個深度繼承的子類繼承了類層次中它的每個祖先的所有屬性,因此便有了超類、基類、父類(都是上級類)以及子類、派生類(繼承而來);
  • 多型(Polymorphism):允許一個介面被多個通用的類動作使用的特性,具體使用哪個動作於應用場合相關。一個介面多種方法。意思是,同樣是 x+y,如果 xy 都是數字,那就是從數學運算;如果 xy 是字串,那就是字串連線;如果是列表,則是列表連線,這就是一個介面多種方法。用於為一組相關的動作設計一個通用的介面,以降低程式複雜性。

在幾乎所有支援物件導向的語言中,都有 class 關鍵字,並且這個關鍵字和麵向物件息息相關。而在 Python 中,通過 class 關鍵字來定義一個類。

我們定義了一個類之後,只要在程式中執行了class class_name,就會在記憶體中生成以這個類名被引用的物件。但是類中的程式碼並不會真正執行,只有在例項化時才會被執行。裡面的方法也不會執行,只有對例項執行方法時才會執行。類是物件,類例項化出來的例項也是物件,叫例項物件。因此類包含了類物件和例項物件,類物件是可以呼叫的物件,而例項物件只能呼叫例項中的方法。

>>> type(list)
<type 'type'>
>>> l1 = [1, 2, 3]
>>> type(l1)
<type 'list'>
複製程式碼

list 是類,l1 是類例項化後的物件。

例項化

建立物件的過程稱之為例項化。當一個物件被建立後,包含三個方面的特性:物件控制程式碼、屬性和方法。控制程式碼用於區分不同的物件,物件的屬性和方法與類中的成員變數和成員函式對應。

定義一個最簡單的類:

>>> class TestClass():
...   pass
... 
>>> type(TestClass)
<type 'classobj'> # 類物件
複製程式碼

呼叫這個類,讓其例項化一個物件:

>>> obj1 = TestClass() # 這就是例項化的過程
>>> type(obj1)
<type 'instance'> # 這是一個例項
複製程式碼

通過 obj1 = TestClass() 例項化了一個物件,之所以在類名後加上括號表示執行這個類中的構造器,也就是類中的 __init__ 方法。其實就和函式名後面加上括號表示執行這個函式是一樣的道理。

從上面可以看出例項初始化是通過呼叫類來建立例項,語法為:

instance = ClassName(args…)
複製程式碼

Python 中,class 語句類似 def,是可執行程式碼,直到執行 class 語句後類才會存在:

>>> class FirstClass: # 類名
        spam = 30 # 類資料屬性
        def display(self): # 類方法,屬於可呼叫的屬性
            print self.spam

>>> x = FirstClass() # 建立類例項,例項化
>>> x.display() # 方法呼叫
複製程式碼

class 語句中,任何賦值語句都會建立類屬性,每個例項物件都會繼承類的屬性並獲得自己的名稱空間。

>>> ins1 = FirstClass()
>>> ins1.
ins1.__class__   ins1.__doc__     ins1.__module__  ins1.display(    ins1.spam

# 這個類就出現了所有的方法,可以看到spam是屬性
複製程式碼

封裝

封裝是面對物件的三大特性之一。在瞭解封裝之前,我們必須知道什麼是 self。

self是啥

通過下面的例子就知道 self 是啥了。

class Foo(object):
    def fun1(self, arg1):
        print(arg1, self)

i1 = Foo()
print(i1)
i1.fun1('hehe')
複製程式碼

執行結果:

<__main__.Foo object at 0x00000000006C32B0>
hehe <__main__.Foo object at 0x00000000006C32B0>
複製程式碼

可以看出 i1 和 self 是同一個東西,由此 self 就是例項化物件後的物件自身,也就是 i1。類只有一個,但是例項化的物件可以有無數個,不同的物件的 self 自然都不相同。

self 是一個形式引數,python 內部自動傳遞。

在瞭解了什麼是 self 之後,現在就可以聊聊封裝了。看下面的例子:

class Foo(object):
    def fetch(self, start):
        print(start)

    def add(self, start):
        print(start)

    def delete(self, start):
        print(start)
複製程式碼

上面的程式碼中,同樣的引數 start 被傳遞到了三個函式中,這樣就顯得很累贅,能否不需要這麼麻煩呢?肯定是可以的。如下:

class Foo(object):
    def fetch(self):
        print(self.start)

    def add(self):
        print(self.start)

    def delete(self):
        print(self.start)

obj1 = Foo()
obj1.start = 'hehe'
obj1.fetch()
複製程式碼

修改後三個函式不再接受引數,這就達到了我們的需求。由於 self 就是物件本身,因此 self.start 就是我們傳遞的“hehe”,這就是類的封裝。

通過在物件中封裝資料,然後在類中通過 self 進行獲取。這是函數語言程式設計無法做到的。這只是類封裝的一種方式,也是一種非主流的方式,下面將會提到主流的方式。

構造器

構造器就是所謂 __init__,它是類的內建方法。建立例項時,Python 會自動呼叫類中的 __init__ 方法。

class Foo(object):
    def __init__(self):
        print('init')

Foo()
複製程式碼

執行結果:

init
複製程式碼

可以看到,我們只要在類名的後面加上括號,就會自動執行類中的 __init__ 函式。通過 __init__ 的這種特性,我們就可以實現主流的封裝方式。

我們可以看到 __init__ 中並沒有 return 語句,但是類初始化後的返回值卻並不為空,因此,例項化一個物件時,還會執行其他的方法。我們可以得出結論:__init__ 不是建立物件,它做的只是初始化物件。

例項化一個物件的過程為:

  1. 建立物件;
  2. 物件作為 self 引數傳遞給 __init__
  3. 返回 self。

以上就是一個物件建立的過程,事實上這個過程我們是可以手動控制的。

class Foo(object):
    def __init__(self):
        self.start = 'hehe'

    def fetch(self):
        print(self.start)

    def add(self):
        print(self.start)

    def delete(self):
        print(self.start)

obj1 = Foo()
obj1.fetch()
複製程式碼

這種方式就比較主流了,當我們要封裝多個變數時,可以通過向 __init__ 函式中傳遞多個引數實現。

class Foo(object):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def fun1(self):
        print('姓名:{},年齡:{}'.format(self.name, self.age))


obj1 = Foo('小紅', 7)
obj2 = Foo('小明', 23)
obj1.fun1()
obj2.fun1()
複製程式碼

執行結果:

姓名:小紅,年齡:7
姓名:小明,年齡:23
複製程式碼

__init__ 方法被稱為構造器,如果類中沒有定義 __init__ 方法,例項建立之初僅是一個簡單的名稱空間。類的 __varname__ 這種方法會被 python 直譯器在某些場景下自動呼叫,就向a+b實際上呼叫的是 a.__add__(b);l1 = ['abc', 'xyz'] 實際上是呼叫 list.__init__()

建構函式的作用就是不需要我們手動呼叫類中的屬性或方法,如果想要在例項化成物件的時候執行,就可以將操作寫入到 __init__ 方法下。

析構器

析構器又稱為解構器,定義的是一個例項銷燬時的操作。也就是當使用 del() 函式刪除這麼一個類時,它會自動呼叫這個類中的 __del__。但是一般而言,直譯器會自動銷燬變數的,因此大多情況下,解構函式都無需過載,但是構造器則不同,它是實現例項變數的一種重要介面。

解構函式就是用於釋放物件佔用的資源,python 提供的解構函式就是 __del__()__del__() 也是可選的,如果不提供,python 會在後臺提供預設解構函式。

析構器會在指令碼退出之前執行,我們可以用它來關閉檔案:

class People(object):
    color = 'yellow'
    __age = 30

    def __init__(self,x):
        print "Init..."
        self.fd = open('/etc/passwd')

    def __del__(self):
        print 'Del...'
        self.fd.close()

ren = People('white')
print 'Main end' # 通過這個判斷__del__是否在指令碼語句執行完畢後執行
複製程式碼

可以看出是在指令碼退出之前執行的:

[root@node1 python]# python c3.py 
Init...
Main end
Del...
複製程式碼

下面是一個析構器的示例:

class Animal:
  name = 'Someone' # 資料屬性(成員變數)
  def __init__(self,voice='hi'): # 過載建構函式
    self.voice = voice # voice有預設值
  def __del__(self): # 這個del就是解構函式,但是它沒有起到任何作用,因為pass了
    pass
  def saysomething(self): # 方法屬性(成員函式)
    print self.voice

>>> tom = Animal()
>>> tom.saysomething()
hi # 預設值為hi
>>> jerry = Animal('Hello!')
>>> jerry.saysomething()
Hello!
複製程式碼

例二:

>>> class Person:
...     def __init__(self,name,age): # 定義一個構造器
...         print 'hehe'
...         self.Name = name
...         self.Age = age
...     def test(self):
...         print self.Name,self.Age
...     def __del__(self): # 定義解構器
...         print 'delete'
...
>>> p = Person('Tom',23)
hehe
>>> del(p) # 刪除例項時立即呼叫解構器
delete
複製程式碼

作用域

函式是作用域的最小單元,那麼在類中有何表現呢?

class E:
    NAME = 'E' # 類的直接下級作用域,叫做類變數

    def __init__(self, name):
        self.name = name # 關聯到物件的變數,叫做例項變數

>>> e = E('e')
>>> e.NAME
Out[4]: 'E'
>>> E.NAME
Out[5]: 'E'
複製程式碼

從上面可以看出,類變數對類和例項都可見。

>>> E.name
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-6-b6daf181be33>", line 1, in <module>
    E.name
AttributeError: type object 'E' has no attribute 'name'
複製程式碼

可以看到,例項變數對例項化後的物件可見,但對類本身並不可見。

>>> e2 = E('e2')
>>> e2.NAME
Out[8]: 'E'
複製程式碼

可以看到,所有例項共享類變數。但是,當其中一個例項修改了類變數呢?

>>> e2.NAME = 'e2'
>>> e.NAME
Out[10]: 'E'
複製程式碼

既然共享了,為什麼其中一個例項修改後不會影響到其他例項呢?例項變數到底是不是共享的呢?我們再看一個例子。

>>> e.xxx = 1 # 可以給物件任意增加屬性
>>> e2.xxx
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-13-8ca2718f6555>", line 1, in <module>
    e2.xxx
AttributeError: 'E' object has no attribute 'xxx'
複製程式碼

之所以出現這樣的情況,是因為 Python 可動態的給物件增減屬性。當給例項的類變數增加屬性時,相當於動態的給這個例項增加了一個屬性,覆蓋了類變數。因為,類變數是共享的這句話並沒有錯。

我們繼續往下看:

>>> E.NAME = 'hehe' # 直接修改類變數
>>> e.NAME
Out[15]: 'hehe'
>>> e2.NAME
Out[16]: 'e2'
複製程式碼

不要感到慌張和迷茫,這裡恰好說明之前的說法都是正確的。因為 e.NAME 並沒有修改,因為使用的仍然是類變數,當類變數修改了,通過 e 去訪問時肯定也會發生變化。而 e2 由於之前修改了,因為這個類變數被覆蓋了,變成了這個物件的私有屬性了,因此不受類變數的影響。

因此,始終都要牢記 Python 中的一大準則,賦值即建立

屬性的查詢順序

事實上通過 e.NAME 訪問,相當於 e.__class__.NAME。而 e.NAME = 1 相當於 e.__dict__['NAME'] = 1。雖然如此,但是會發生下面這樣的情況。

>>> e.NAME
Out[6]: 'E'
>>> e.__dict__['NAME'] = 1
>>> e.NAME
Out[8]: 1
>>> e.__class__.NAME
Out[9]: 'E'
複製程式碼

通過 e.NAME 和 e.__class__.NAME 訪問的結果卻不一樣,這是為什麼呢?這就涉及到屬性的查詢順序了:

__dict__ -> __class__
複製程式碼

由於在 __dict__ 中可以找到 NAME,所以就直接返回了,而不會在 __class__ 中繼續找。

__class__ 是例項對類的一個引用。因此 e.__class__.NAME 這個值就是類中 NAME 的值,它不會改變。而我們修改例項的屬性,則是新增到例項的 __dict__ 中。

裝飾器裝飾類

給類加裝飾器就是動態的給類增加一些屬性和方法。

看下面的例子:

def set_name(cls, name):
    cls.NAME = name
    return cls

class F:
    pass

>>> F1 = set_name(F, 'F')
>>> F1.NAME
Out[16]: 'F'
複製程式碼

事實上 set_name 就相當於一個裝飾器。那我們可以使用裝飾器的語法將其重寫一下:

def set_name(name):
    def wrap(cls):
        cls.NAME = name
        return cls
    return wrap
    
@set_name('G')
class G:
    pass
 
>>> G.NAME
Out[19]: 'G'
複製程式碼

結果證明是沒有問題的,其實使用裝飾器語法就相當於:

G = set_name('G')(F) # F 是前面定義的類
複製程式碼

還可以通過裝飾器給類新增方法:

def print_name(cls):
    def get_name(sel): # 必須傳遞一個引數給它,不然不能通過例項來呼叫
        return cls.__name__
    cls.__get_name__ = get_name
    return cls

@print_name
class H:
    pass

>>> h = H()
>>> h.__get_name__()
Out[24]: 'H'
複製程式碼

只不過類裝飾器通常用於給類增加屬性的,而增加方法則有更好的方式。

屬性和方法

類中的變數稱為屬性、函式稱為方法。它們又有靜態屬性、靜態方法、動態屬性、動態方法、類方法等之分。

方法的定義都是類級的,但是有的方法使用例項呼叫,用的方法通過類呼叫。

例項方法和屬性

例項方法和屬性都是與 self 相關的,因此只能通過例項進行訪問。例項方法的第一個引數是例項名,預設即是如此。由於類根本不知道例項(self)是什麼(因為還沒有例項化),因此不能通過類直接例項方法和例項屬性。

class Foo:
    def __init__(self, name):
        self.name = name # 例項屬性

    def f1(self): # 例項方法
        print('f1')
複製程式碼

類方法和屬性

類屬性前面提到過了,定義在類作用域下的變數就是類屬性。它可以通過類和例項直接訪問。

類方法類似於靜態方法,它可以通過類直接訪問。與靜態方法的區別在於,它可以獲取當前類名。第一個引數為類本身的方法叫做類方法。類方法可以通過例項進行呼叫,但是第一個引數依然是類本身。

class Foo:
    @classmethod # 修飾為類方法
    def f2(cls): # 必須接受一個引數
複製程式碼

類方法必須接受一個引數,它是由類自動傳遞的,它的值為當前類名。也就是說,通過 classmethod 裝飾器會將自動傳遞給方法的第一個引數(之前為例項名)改為類名。而被裝飾的方法的引數名和 self 一樣,不強制要求為 cls,只是習慣這麼寫而已。

類方法的最大的用處就是無需例項化即可使用。

靜態方法

不同於例項方法和類方法的必須擁有一個引數,靜態方法不需要任何引數。

class Foo:
    @staticmethod # 裝飾為靜態方法
    def f1(): # 沒有任何引數
        print('static method')
複製程式碼

被 staticmethod 裝飾器裝飾後,訪問的時候不會自動傳遞第一個引數。靜態方法和類方法一樣,可以同時被類和例項訪問。

class Foo:
    @staticmethod
    def f1():
        print('static method')

    def f2(): # 可以被類訪問
        print('hehe')
複製程式碼

f1 和 f2 的區別在於,f2 無法通過例項訪問。

私有方法和屬性

以雙下劃線開頭,且非雙下劃線結尾的函式/變數就是私有方法/屬性,在類的外部無法訪問。我們可以得出結論:所有以雙下劃線開頭,且非雙下劃線結尾的成員都是私有成員。

通過下面的例子可以看到它的用處。

class Door:
    def __init__(self, number, status):
        self.number = number
        self.__status = status

    def open(self):
        self.__status = 'opening'
    
    def close(self):
        self.__status = 'closed'

>>> door = Door(1, 'closed')
>>> door.__status # 直接訪問會報錯
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-35-d55234f04e7f>", line 1, in <module>
    door.__status
AttributeError: 'Door' object has no attribute '__status'
複製程式碼

但是可以直接修改它的屬性:

>>> door.__status
Out[37]: 'hehe'
>>> door.open()
>>> door.__status
Out[39]: 'hehe'
複製程式碼

雖然賦值即定義,但是還是有些無法接受。在外面應該不能修改它才對。

私有屬性雖然無法直接訪問,但是並不絕對,Python 提供了訪問它的方法。

_類名 + 私有屬性
複製程式碼

比如這麼訪問:

door._Door__status
複製程式碼

因此,嚴格的說,Python 中沒有真正的私有成員。

我們可以通過這個方式修改私有屬性:

door._Door__status = 'hehe'
複製程式碼

但除非真的有必要,並且清楚的知道會有什麼後果,否則不要這麼幹。

類中還有以單下劃線開頭的屬性,這是一種慣用法,標記它為私有屬性,但是直譯器並不是將其當做私有屬性處理。

property

property 裝飾器會把一個僅有 self 引數的函式變成一個屬性,屬性的值為方法的返回值。

class Foo:
    @property
    def f1(self):
        print('f1')


obj = Foo()
obj.f1 # 不需要加括號了
複製程式碼

結合之前的裝飾器,理解下面的例子:

def f(fn):
    @property
    def abc(self):
        print('abc')
        fn(self)
    return abc

class A:
    @f
    def t1(self):
        print('t1')

a = A()
a.t1
複製程式碼

通過 property 可以將方法修飾為欄位,但是屬性的值可以修改,而使用 property 修飾的函式的返回值卻無法修改,因為它無法接受引數。從這裡看,property 好像就只能在呼叫的時候少些兩個括號而已,但是它並沒有這麼簡單。

class Foo:
    def __init__(self, name):
        self.name = name

    @property
    def f1(self):
        return ('f1')

>>> obj = Foo('hello')
>>> obj.name
hello
>>> obj.name = 'hehe' # 可以修改
>>> obj.name
Out[6]: 'hehe'
>>> obj.f1 = 'xxx' # 這麼肯定報錯
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-7-6e871c456103>", line 1, in <module>
    obj.f1 = 'xxx'
AttributeError: can't set attribute
複製程式碼

由於 property 裝飾器限制了函式不能接收引數,因此不能給它傳參,也就難以修改裡面的值了。但是,如果想要修改 property 函式中的值也是可以的,這就用到了它的第二個功能了。

class Foo:
    def __init__(self, name):
        self.name = name

    @property
    def f1(self):
        return self.name

    @f1.setter # f1 是函式名,必須和 property 修飾的函式一致
    def f1(self, value):
        self.name = value

obj = Foo('hello')
print(obj.f1) # 結果是 hello
obj.f1 = 'xxx'
print(obj.f1) # 結果是 xxx
複製程式碼

因此,property setter 裝飾器可以把一個方法轉化為對此賦值,但此方法有一定要求:

  1. 同名;
  2. 必須接收 self 和 value 兩個引數,value 為所賦的值。

有了 property setter 裝飾器之後,被 property 裝飾的函式就可以接收引數了。相應的,我們可以通過這個引數來達到我們的一些目的。

除了 setter 之外,還有一個 deleter 的裝飾器,這也是 property 的第三個功能。當刪除 property 裝飾器裝飾的函式(由於被 property 裝飾,因此函式變成屬性)時,會呼叫 deleter 裝飾的函式。

class Foo:
    def __init__(self, name):
        self.name = name
    
    @property
    def f1(self):
        return self.name
    
    @f1.deleter
    def f1(self):
        print('hehe')

>>> obj = Foo('f1')
>>> del obj.f1
hehe
複製程式碼

事實上 del 是不能刪除方法的,但是由於函式被 property 裝飾後會變成屬性,因此可以被刪除。

可以看出 property 很強大,它不僅可以作為裝飾器,還可以引申出來兩個裝飾器,只不過需要定義三個函式。其實它完全可以定義的更簡單,而達到相同的效果。下面就是將之前定義的三個函式通過一行程式碼取代。

f1 = property(lambda self: self.name, lambda self, value: self.name = value, lambda: self: print('hehe'))
複製程式碼

f1 這個函式接收三個函式作為引數,第一個函式是必須的,後面兩個可以省略。正好對應 property, f1.setter, f1.deleter 裝飾的三個函式。

下面定義了兩個類,第二個類比第一個類中多了一個繼承物件 object,其他的都一樣,並且呼叫方式也相同。我們看看結果,然後進行對比:

class test1:
    def __init__(self,flag):
        self.__pravite = flag
    
    @property
    def show(self):
        return self.__pravite

class test2(object):
    def __init__(self,flag):
        self.__pravite = flag
    
    @property
    def show(self):
        return self.__pravite

t1 = test1('t1')
print t1.show
t1.show = 'x1'
print t1.show

t2 = test2('t2')
print t2.show
t2.show = 'x2'
print t2.show
複製程式碼

同樣進行修改,然後進行訪問。以下是執行結果:

t1
x1
t2
Traceback (most recent call last):
  File "E:\workspace\test\main\t1.py", line 48, in <module>
    t2.show = 'x2'
AttributeError: can't set attribute
複製程式碼

可以看出,如果不繼承 object,不使用 @xxx.setter 裝飾器,私有屬性是可以直接修改的。但是如果繼承了 object,那就必須使用不使用 @xxx.setter 裝飾器了,不然無法修改。

繼承

繼承是面對物件的重要特性之一,前面提到的都屬於“封裝”。繼承是相對兩個類而言的父子關係,子類繼承了父類的所有公有屬性和方法,繼承實現了程式碼的重用。Python 允許多繼承,也就是說一個類可以繼承多個父類,這是其他物件導向程式語言(C#, Java 等)所不具備的。多繼承時,哪個類放在前面,哪個類就最終繼承。也就是說兩個類中都有相同的屬性或方法時,寫在前面的類是子類繼承的類。

子類也稱為派生類,父類也稱為基類。

派生類可以繼承父類中的所有內容,當派生類和父類中同時存在相同的內容時,優先使用自己的。也就是說當例項化子類時,如果執行父類中 self.abc 程式碼,首先會在當前類(也就是子類中)查詢 abc,因為例項化的是子類而非父類,只要找不到才會去父類中找。如果是多繼承,也是一級一級的往上找。

如果子類中沒有定義初始化方法,例項化時會執行父類中的初始化方法。而如果子類中存在,就不會執行父類的。如果想要執行父類中的初始化方法,可以使用 super 函式,下面會講到。

繼承描述了基類的屬性如何“遺傳”給派生類。

  • 子類可以繼承它的基類的任何屬性,包括資料和方法;
  • 一個未指定基類的類,其預設有一個名為 object 的基類;
  • Python 允許多重繼承,也就是說可以有多個並行的父類。

建立子類時,只需要在類名後跟一個或從其中派生的父類:

class SubClassName(ParentClass1[,ParentClass2,…])
複製程式碼

凡是公有的都能繼承,凡是私有的都不能繼承。因為私有的繼承後會改名,這就會導致找不到(改下名後就能訪問到了)。原來是什麼,繼承過來還是什麼。類變數繼承過來還是類變數。

當我們對一個子類進行例項化時,這個例項化物件也是父類的例項化物件,比如:

class A:
    pass
class B(A):
    pass

>>> a = B()
>>> isinstance(a, A)
Out[6]: True
複製程式碼

方法重寫

當子類和父類中都擁有相同的成員(包括屬性和方法)時,子類會使用自己的而不是父類的,這是繼承的規則。但是這樣會導致子類無法使用父類中的同名方法。為了解決這一問題,Python 中引入了 super 類。

也就是說只有要進行方法重寫的時候才會使用 super。

super 的使用方法:

super() -> same as super(__class__, <first argument>)
super(type) -> unbound super object
super(type, obj) -> bound super object; requires isinstance(obj, type)
super(type, type2) -> bound super object; requires issubclass(type2, type)
複製程式碼

可以看出 super 可以不接受引數,也可以最多接收兩個引數。當 super 不帶引數時,它的意義和 super(子類名, self) 相同。

# 定義一個父類
class Base:
    def print(self):
        print('Base.print')

# 子類中也有 print 方法,很顯然會覆蓋父類的 print 方法
class Sub(Base):
    def print(self):
        print('Sub.print')
    def foo(self):
        # 通過 super 呼叫父類的同名方法,以下兩種寫法作用相同
        super(Sub, self).print()
        super().print()
        
>>> Sub().foo() # 明顯兩種寫法作用相同
Base.print
Base.print
複製程式碼

這是針對例項方法的,它同樣可以針對類方法:

class Base:
    @classmethod
    def cls_print(cls):
        print('Base.cls_print')

class Sub(Base):
    @classmethod
    def cls_print(cls):
        print('Sub.cls_print')

    @classmethod
    def cls_foo(cls):
        # 由於是類方法,因此可以直接通過父類進行訪問
        Base.cls_print()
        # 可以針對類方法
        super().cls_print()
        # 這種會報錯
        #super(Base, cls).cls_print()
        
>>> Sub().cls_foo()
Base.cls_print
Base.cls_print
複製程式碼

我們可以得出結論,90% 的情況下 super 不需要帶引數。下面介紹的就是那 10%。

class Base:
    def print(self):
        print('Base.print')

class Sub(Base):
    def print(self):
        print('Sub.print')

# 我們定義了子類,但是繼承的是 Sub,父子孫中都擁有同名的方法
class Subsub(Sub):
    def print(self):
        print('Subsub.print')

    def foo(self):
        # 當要呼叫父類的父類中的同名方法時,super 就要帶引數了
        super(Sub, self).print()

>>> Subsub().foo()
Base.print
複製程式碼

回到一開始列出的 super 的使用方法,可以得出結論:super 代理 TYPE 的父類方法,並且使用 obj 繫結。第一個引數是指呼叫誰的直接父類,第二個引數是指呼叫時,傳遞給方法的第一個引數。

帶引數的__init__

看一個示例:

class Base:
    def __init__(self, a, b):
        # 定義兩個私有屬性
        self.__a = a
        self.__b = b

    def sum(self):
        return self.__a + self.__b

class Sub(Base):
    def __init__(self, a, b, c):
        self.c = c
        self.__a = a
        self.__b = b

>>> Sub(1, 2, 3).sum()
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-3-e982c4a04055>", line 1, in <module>
    Sub(1, 2, 3).sum()
  File "<ipython-input-2-7fde2b10dc1f>", line 7, in sum
    return self.__a + self.__b
AttributeError: 'Sub' object has no attribute '_Base__a'
複製程式碼

報錯了。如果初始化函式中的屬性不是私有的話,是不會報錯的。但是私有屬性一定會報錯,因為私有屬性是無法繼承的。為了讓它不報錯,就可以用到 super 了。

class Base:
    def __init__(self, a, b):
        self.__a = a
        self.__b = b

    def sum(self):
        return self.__a + self.__b

class Sub(Base):
    def __init__(self, a, b, c):
        self.c = c
        # 直接呼叫父類的初始化方法
        super().__init__(a, b)
     
>>> Sub(1, 2, 3).sum()
Out[6]: 3
複製程式碼

如果繼承父類,那麼定義在父類 __init__ 中的相同的屬性會覆蓋子類中的。

如果父類含有一個帶引數的初始化方法的時候,子類一定需要一個初始化方法,並且在初始化方法中呼叫父類的初始化方法。

Python2 中還能通過下面的方法繼承父類中同名的方法,但是很顯然 super 完全可以替代它,因為 super 可以指定繼承哪一個父類中同名的成員。

父類.__init__(self[, arg1, arg2...])
複製程式碼

super獲取類變數

前面通過 super 獲取的是方法,這次獲取的是變數:

class Base:
    NAME = 'BASE'


class Sub(Base):
    NAME = 'SUB'

    def print(self):
        print(self.NAME)
        print(super(Sub, Sub).NAME)
        
>>> Sub().print()
SUB
BASE
複製程式碼

例項變數是無法獲取的,因為父類並沒有例項化,例項變數是不存在的,因此肯定是無法繼承的。

還有一種情況:

class Base:
    NAME = 'BASE'

class Sub(Base):
    NAME = 'SUB'

    def print(self):
        print(self.NAME)
        print(super(Sub, Sub).NAME)
        print(Base.NAME)
複製程式碼

最後兩行在單繼承環境下沒有區別,但是在多級繼承時存在區別。

多繼承

python 支援多繼承,而 Python3 中的所有類都會繼承 object 這個類,因此下面這三種寫法意義相同:

class A:
    pass
    
class A(object):
    pass

class A():
    pass
複製程式碼

我們可以看到 object 中包含的所有方法和屬性:

In [4]: dir(A())
Out[4]: 
['__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__']
複製程式碼

所以,我們只要定義一個類,天生就擁有這麼多的屬性和方法。

多繼承的寫法:

class Sub(Base, Base2) # 繼承列表中有多個類就表示多繼承
複製程式碼

多繼承會把繼承列表中的所有公有成員都繼承過來,當有同名成員時,就會有一個繼承順序了。

多繼承的查詢順序

在討論類繼承順序之前,我們首先要了解 MRO,它的本意就是方法查詢順序。它要滿足幾個條件:

  • 本地優先:自己定義或重寫的方法優先。本地沒有的,按照繼承列表,從左往右查詢;
  • 單調性:所有子類,也要滿足查詢順序。也就是說 A 繼承 B C,A 會先找 B 再找 C。但是在 A 查詢之前,B 如果有多個繼承,那麼它先得按查詢順序查詢。

如果定義一個多繼承的類,如果不能滿足 MRO 的話,會丟擲 MRO 的異常。

class A:
    pass

class E(A):
    pass

class F(A, E):
    pass

>>> F()
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-9-491a467e42f0>", line 7, in <module>
    class F(A, E):
TypeError: Cannot create a consistent method resolution
order (MRO) for bases A, E # 丟擲 MRO 異常,原因下面講
複製程式碼

MRO 是可以看到的,因為類中存在這個屬性。

>>> A.__mro__
Out[10]: (__main__.A, object)
>>> E.__mro__
Out[11]: (__main__.E, __main__.A, object)

# 再定義一個 G
class G(E, A):
    pass

>>> G.__mro__
Out[17]: (__main__.G, __main__.E, __main__.A, object)
複製程式碼

可以看到 G 的查詢順序是 G -> E -> A -> object,既能滿足 A 的順序,也能滿足 E 的順序,所以我們說 G 的定義滿足了它們 MRO 的單調性。但是如果 F 能夠定義的話,它的查詢順序是 F -> A -> E -> object,很顯然既滿足不了 A,也不能滿足 E,因此就會丟擲 MRO 異常。

Python 和其他語言通過 c3 演算法檢測類是否滿足了 MRO 的兩個原則,演算法的解釋是:

class B(O) -> [B, O] # 最終查詢順序要是這樣
class B(A1, A2, ..., An) -> [B] + merge(mro(A1), mro(A2), ..., mro(An), [A1, A2, ..., An, O])
複製程式碼

它實際上會使用遞迴,結果類似於第一行的列表(正確的 MRO)為退出條件。

merge 有四個步驟:

  1. 遍歷列表。它會先求出 A1 到 An 所有類的 MRO,它會形成一個列表,merge 的引數也就是這個列表;
  2. 看一個列表的首元素,它存在兩種情況:
    • 它在其他列表中也是首元素;
    • 它在其他列表中不存在;
  3. 如果首元素滿足上面兩種情況中的一種,那麼會將其從列表中移除,合併到 MRO;
  4. 不滿足的話,丟擲異常。否則不斷迴圈,直到列表為空。

大致過程是這樣的(其中 G, E, A 都是一個類,O 表示 object,最後的 [E, A, O] 表示繼承的順序):

mro(G) -> [G] + merge(mro[E], mro[A], [E, A, O])
	-> [G] + merge([E, A, O], [A, O], [E, A, O]) # E 在所有列表的首部,因此拿出來
	-> [G, E] + merge([A, O], [A, O], [A, O]) 
	-> [G, E, A] + merge([O], [O], [O])
	-> [G, E, A, O] # 最終就只剩下這個列表
複製程式碼

當定義一個類的時候,直譯器會指定 c3 演算法來確認 MRO,如果 c3 演算法丟擲異常,此類不能定義。

我們應該儘量避免多繼承。繼承多了很容易就搞懵了,並且 python 邊解釋便執行,如果不呼叫類,它也不會知道類的定義有沒有錯誤。當我們以為繼承是對的時候,但是某天突然報錯了,可能都不知道是什麼原因造成的。

以下就是 MRO 的圖形展示。

image_1b0jrmk4ocdh1j8nlem40gjqp9.png-14.7kB

如上圖所示,A 同時繼承 B 和 C,B 繼承 D,C 繼承 E。當 A 中沒有對應的方法時,會先在 B 找,找不到會找 D 而不會找 C,最後找 E。

image_1b0jruq3t14j77rd13db1hujmjam.png-16kB

和上圖基本相同,就多了個 D 和 E 同時繼承 F。這裡 D 找不到時不會找F而是會找 C,F 是最後一個找的。

也就是說如果沒有共同繼承的基類,會一直往上找。而共同繼承的基類最後一個找。

但是還有下面這種情況:

image_1b0k16v3k20fgch1v0k49o5q52n.png-14.4kB

class D:
    def xxx(self):
        print('xxx')
        self.f()


class C:
    def f(self):
        print('C')


class B(D):
    def f(self):
        print('B')


class A(B, C):
    pass


obj = A()
obj.xxx()
複製程式碼

繼承關係和前面一樣,當執行 XXX 方法時,由於 B 裡面沒有,所以找到了 D。但是 D 中會執行 self.f(),那麼 f 函式會在哪個函式中找呢?答案是B。

執行 self.f() 時,self 是 obj,而 obj 又是從 A 中例項化而來。因此執行 self.f() 會現在 A 中找 f 這個函式,如果沒有,肯定在 B 中找。因此答案是 B。

Mixin

要給一個類動態的增加方法,有多種方式:

  • 可以通過繼承的方式,但是如果繼承的類是標準庫中的,由於無法修改,所以行不通。
  • 通過類裝飾器,唯一的問題是裝飾器無法繼承。
class Document:
    def __init__(self, content):
        self.content = content

class Word(Document):
    def __init__(self, content):
        super().__init__('word: {}'.format(content))

def printable(cls):
    def _print(self): # 給類加了這個方法
        print('P: {}'.format(self.content))
    cls.print = _print
    return cls

@printable
class PrintableWord(Word):
    def __init__(self, content):
        super().__init__(content)

>>> PrintableWord('abc').print()
P: word: abc
複製程式碼
  • Mixin 的方式。它就是繼承一個類,在這個類中增加方法,就能達到給目標類增加功能的目的。
class Document:
    def __init__(self, content):
        self.content = content


class Word(Document):
    def __init__(self, content):
        super().__init__('word: {}'.format(content))


class PrintableMixin:
    def print(self):
        print('P: {}'.format(self.content))


class PrintableWord(PrintableMixin, Word):
    def __init__(self, content):
        super().__init__(content)


>>> PrintableWord('abc').print()
P: word: abc
複製程式碼

使用 Minix 的好處在於,通過定義類的方式新增的動態方法是可以被其他類繼承的。

class Document:
    def __init__(self, content):
        self.content = content


class Word(Document):
    def __init__(self, content):
        super().__init__('word: {}'.format(content))


class PrintableMixin:
    def print(self):
        result = 'P: {}'.format(self.content)
        print(result)
        return result


class PrintableWord(PrintableMixin, Word):
    def __init__(self, content):
        super().__init__(content)

# 再次被繼承
class PrintToMonitorMixin(PrintableMixin):
    def print(self):
        print('Monitor: {}'.format(super().print()))


class PrintToMonitorWord(PrintToMonitorMixin, Word):
    pass


>>> PrintToMonitorWord('abc').print()
P: word: abc
Monitor: P: word: abc
複製程式碼

Mixin 是通過多繼承實現的組合方式。通常來說,組合優於繼承。socketserver 就用到了 Mixin。

多型

多型也是面對物件的特性之一,意思是多種形態。但是不同於 C# 和 java,python 中的多型是原生的,這應該也是弱型別語言的特性,因此 python 中的多型很少有提到。

class Foo:
    def f1(self):
        print('Foo')


class Bar:
    def f1(self):
        print('Bar')


def fun(arg):
    arg.f1()


fun(Foo())
fun(Bar())
複製程式碼

執行沒有任何問題,因為python中什麼引數都能接收,你給我什麼我就接收什麼。但是在 Java 中函式 fun 不能這麼寫,只能寫成 fun(Foo arg)fun(Bar arg)。這就限定死了它只能接受一種類建立的物件,因此它們才有實現多型的需求。

其他語言的多型是這樣實現的:

class Father:
    pass


class Foo(Father):
    def f1(self):
        print('Foo')


class Bar(Father):
    def f1(self):
        print('Bar')


def fun(Father arg):
    arg.f1()


fun(Foo())
fun(Bar())
複製程式碼

給它一個父類即可,這樣父類和子類都可以傳遞。通過相同的父類實現多型。Python 中的方法重寫、運算子過載都是多型的體現。

特有方法

前面我們就已經看到了,定義一個類時,這個類會從 object 中繼承很多以雙下劃線開頭和雙下劃線結尾的成員,這些成員中有的是屬性,有的是方法。

class A:
     pass
 
>>> dir(A)
Out[4]: 
['__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__']
複製程式碼

__name__

獲得類的名字。

>>> A.__name__
Out[9]: 'A'
複製程式碼

注意,例項是沒有這個屬性的。

__module__

獲取模組名。ipython 並不知道它的模組名,因為結果為 main:

>>> A.__module__
Out[11]: '__main__'
複製程式碼

__doc__

顯示文件字串。

>>> A.__doc__
複製程式碼

__class__

python 一切皆物件,類也是物件,所有類都是 type 的物件。

>>> A.__class__
Out[13]: type
複製程式碼

而例項的 class 則是它的類:

>>> A().__class__
Out[14]: __main__.A
複製程式碼

因此我們可以獲取例項的類名:

>>> A().__class__.__name__
Out[15]: 'A'
複製程式碼

__dict__

針對例項的,它持有所有例項擁有的屬性。我們給例項增加屬性就是給這個字典增加 key,這也是例項可以動態增加屬性的原因。

__dir__

它會得到例項的所有成員,類並沒有這個方法。dir() 底層就是呼叫它。

>>> A().__dir__()
Out[19]: 
['__module__',
 '__dict__',
 '__weakref__',
 '__doc__',
 '__repr__',
 '__hash__',
 '__str__',
 '__getattribute__',
 '__setattr__',
 '__delattr__',
 '__lt__',
 '__le__',
 '__eq__',
 '__ne__',
 '__gt__',
 '__ge__',
 '__init__',
 '__new__',
 '__reduce_ex__',
 '__reduce__',
 '__subclasshook__',
 '__init_subclass__',
 '__format__',
 '__sizeof__',
 '__dir__',
 '__class__']
複製程式碼

__hash__

當我們傳遞一個物件給內建方法 hash() 時,它能夠返回一個整數。

>>> hash('abc')
Out[2]: 1751202505306800636
複製程式碼

事實上它是呼叫類的 __hash__ 方法。

class Point:
    def __hash__(self):
        return 1
    
>>> hash(Point())
Out[3]: 1
複製程式碼

但是 __hash__ 的返回值必須是一個整數,否則會報錯。

class Point:
    def __hash__(self):
        return 'a'
    
>>> hash(Point())
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-5-a919dcea3eae>", line 1, in <module>
    hash(Point())
TypeError: __hash__ method should return an integer
複製程式碼

__hash__ 有什麼用呢?一個類中有這個方法,並且返回一個整數,那麼它的例項物件就是可雜湊物件。而字典和集合中只能新增可雜湊物件,也就說它們在新增之前會呼叫 hash 方法。

class Point:
    # 將 __hash__ 直接幹掉
    __hash__ = None
    
 >>> set([Point()]) # 新增時直接丟擲異常
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-7-b7932f1140b9>", line 1, in <module>
    set([Point()])
TypeError: unhashable type: 'Point' 
複製程式碼

一個類,如果沒有重寫 __hash__ 方法,這個類的每個物件,將會具有不同的 hash 值。這會造成什麼問題呢?我們定義一個類:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        
>>> a = Point(2, 3)
>>> b = Point(2, 3)
>>> set([a, b])
Out[11]: {<__main__.Point at 0x7f410bc5e0b8>, <__main__.Point at 0x7f410bc5e358>}
複製程式碼

事實上我們認為 a 和 b 是完全相同的,但是由於它們的雜湊值不同,set 並不會將其當成一個物件,因為無法通過 set 進行去重。那麼應該怎麼辦呢?我們需要對 __hash__ 進行重寫。

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

    def __hash__(self):
        return hash('{}:{}'.format(self.x, self.y))

>>> a = Point(2, 3)
>>> b = Point(2, 3)
>>> hash(a) == hash(b)
Out[15]: True
>>> set([a, b])
Out[16]: {<__main__.Point at 0x7f410b399908>, <__main__.Point at 0x7f410b399d68>}
複製程式碼

它們 hash 是相等了,但是還是不能通過 set 進行去重。這是因為 set 不光要檢查元素 hash 值,還會檢查類本身是否相同。很顯然,a 和 b 並不相同:

>>> a == b
Out[17]: False
複製程式碼

如果想讓它們相等,那我們得重寫 __eq__ 方法:

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

    def __hash__(self):
        return hash('{}:{}'.format(self.x, self.y))

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

>>> a = Point(2, 3)
>>> b = Point(2, 3)
>>> set([a, b]) # 去重成功
Out[21]: {<__main__.Point at 0x7f410b376588>}
複製程式碼

通常 __hash__ 會和 __eq__ 同時使用,因為直譯器同時判斷 hash 和例項是否相等。因此當我們的例項要放在字典或者集合中時,我們就要在類中實現它們。

__len__

內建函式 len 就是呼叫類本身的 __len__ 方法。

>>> class Sized:
...     def __len__(self):
...         return 10
...     
>>> len(Sized())
Out[23]: 10
複製程式碼

由於 object 中並沒有 __len__,因此需要我們手動實現。需要注意的是,__len__ 必須返回整數,且必須大於 0。

自定義資料結構的時候會用到它。

__bool__

通過 bool 這個內建方法可以判斷一個物件的真假,事實上就是呼叫物件本身的 __bool__ 方法。

class O:
    pass

>>> bool(O()) # 預設為真
Out[24]: True

class O:
    # 定義它為假
    def __bool__(self):
        return False
    
>>> bool(O()) # 那就為假
Out[25]: False
複製程式碼

事實上並沒有這麼簡單,因為列表並沒有實現 __bool__,但是空列表會返回假,列表有元素就返回真。判斷依據是什麼?空列表的 __len__ 是 0。

class Sized:
    def __init__(self, size):
        self.size = size
    
    def __len__(self):
        return self.size
        
>>> bool(Sized(0))
Out[31]: False
>>> bool(Sized(1))
Out[32]: True
複製程式碼

當物件沒有實現 __bool__,而實現了 __len__ 時,__len__ 等於 0 返回假,否則為真;如果這兩種方法都沒有實現,返回真;而當這兩種方法同時出現時,__bool__ 優先順序更高。

__bool__ 必須返回 True 和 False。

__str__

當我們 print 一個物件時,就會呼叫這個方法。

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __str__(self):
        return 'Point<{}, {}>'.format(self.x, self.y)
    
>>> print(Point(1, 3))
Point<1, 3>
複製程式碼

'{!s}'.format() 也是呼叫類的 __str__ 方法。它在除錯程式、打日誌的時候很有用。

__repr__

內建方法 repr'{!r}'.format() 就是呼叫這個方法。str 通常給人讀,而 repr 則是給機器讀的。因此我們會重寫 str,但是很少會重寫 repr。比如下面這種就適合給機器讀而不適合給人讀:

<__main__.A at 0x7fb30cd64860>
複製程式碼

__repr__ 到底實現了什麼呢?當我們在互動式模式下例項化一個類時:

>>> class A: pass
... 
>>> a = A()
>>> a
Out[11]: <__main__.A at 0x7f9cc1fd1898>
複製程式碼

a 的結果就是呼叫了 __repr__ 方法,比如:

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

    def __repr__(self):
        return 'A({0.x}, {0.y})'.format(self)

>>> a = A(2, 5)
>>> a
Out[16]: A(2, 5)
複製程式碼

上面的 format() 方法的使用看上去很有趣,格式化程式碼 {0.x} 對應的是第一個引數的 x 屬性。 因此,0 實際上指的就是 self 本身。作為這種實現的一個替代,你也可以使用 % 操作符,就像下面這樣:

def __repr__(self):
    return 'A(%r, %r)' % (self.x, self.y)
複製程式碼

__repr__() 生成的文字字串標準做法是需要讓 eval(repr(x)) == x 為真。 如果實在不能這樣子做,應該建立一個有用的文字表示,並使用 <> 括起來。比如:

>>> f = open('file.dat')
>>> f
<_io.TextIOWrapper name='file.dat' mode='r' encoding='UTF-8'>
複製程式碼

如果 __str__() 沒有被定義,那麼就會使用 __repr__() 來代替輸出。

__format__

為了自定義字串的格式化,我們需要在類上面定義 __format__() 方法。例如:

_formats = {
    'ymd' : '{d.year}-{d.month}-{d.day}',
    'mdy' : '{d.month}/{d.day}/{d.year}',
    'dmy' : '{d.day}/{d.month}/{d.year}'
    }

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    def __format__(self, code):
        if code == '':
            code = 'ymd'
        fmt = _formats[code]
        return fmt.format(d=self)
複製程式碼

現在 Date 類的例項可以支援格式化操作了,如同下面這樣:

>>> d = Date(2012, 12, 21)
>>> format(d)
'2012-12-21'
>>> format(d, 'mdy')
'12/21/2012'
>>> 'The date is {:ymd}'.format(d)
'The date is 2012-12-21'
>>> 'The date is {:mdy}'.format(d)
'The date is 12/21/2012'
複製程式碼

__format__() 方法給 Python 的字串格式化功能提供了一個鉤子,這裡需要著重強調的是格式化程式碼的解析工作完全由類自己決定。因此,格式化程式碼可以是任何值。例如,參考下面來自 datetime 模組中的程式碼:

>>> from datetime import date
>>> d = date(2012, 12, 21)
>>> format(d)
'2012-12-21'
>>> format(d, '%A, %B %d, %Y')
'Friday, December 21, 2012'
>>> 'The end is {:%d %b %Y}. Goodbye'.format(d)
'The end is 21 Dec 2012. Goodbye'
>>>
複製程式碼

__call__

一個物件中只要存在該方法,那麼就可以在它的後面加上小括號執行。函式在 Python 也是物件,所有函式都是 function 的例項:

>>> def fn():
...     pass
... 
>>> fn.__class__
Out[4]: function
複製程式碼

函式之所以可以被呼叫執行,就是因為其內部存在 __call__ 方法,因此我們只要在類的內部定義這個方法,那麼該類的例項就可以被呼叫。而這種物件我們稱之為可呼叫物件

>>> class Fn:
...     def __call__(self):
...         print('called')
...         
>>> Fn()()
called
複製程式碼

內建方法 callable 可以用來判斷一個物件是否可被呼叫。

它可以用類來寫裝飾器,讓你和使用函式寫的裝飾器一樣用。之所以使用類來寫裝飾器是因為非常複雜的裝飾器,使用類來寫的話,可以方便拆分邏輯。並且之前我們要為函式儲存一些變數需要通過閉包來實現,現在完全可以使用類的 __call__ 方法。用 __call__ 實現可呼叫物件,和閉包是殊途同歸的,通常都是為了封裝一些內部狀態。

__enter__

詳見上下文管理。

__exit__

詳見上下文管理。

__getattr__

直接看例子:

>>> class A:
...     def __init__(self):
...         self.x = 3
... 
...     def __getattr__(self, item):
...         return 'missing property {}'.format(item)
...     
>>> a = A()
>>> a.x
Out[20]: 3
>>> a.y
Out[21]: 'missing property y'
>>> a.z
Out[22]: 'missing property z'
複製程式碼

當一個類定義了 __getattr__ 方法時,如果訪問不存在的成員,會呼叫該方法。因此一個物件的屬性查詢順序為:__dict__ -> class -> __getattr__。當 dict 和 class 中都不存在時,就會執行 getattr。比如字典的 setdefault 方法。

__setattr__

當一個類實現了 __setattr__ 時,任何地方對這個類增加屬性,或者對現有屬性賦值時,都會呼叫該方法。

>>> class A:
...     def __init__(self):
...         self.x = 3
... 
...     def __setattr__(self, name, value):
...         print('set {} to {}'.format(name, value))
...         
>>> a = A()
set x to 3
>>> a.y = 5
set y to 5
複製程式碼

例項化的時候,由於例項化方法中存在賦值的行為,因此觸發 __setattr__。此時的 self.x 沒有賦值,但是我們是可以進行賦值的。

>>> class A:
...     def __init__(self):
...         self.x = 3
... 
...     def __setattr__(self, name, value):
...         print('set {} to {}'.format(name, value))
...         self.__dict__[name] = value # 增加這一行即可
...         
>>> a = A()
set x to 3
>>> a.y = 5
set y to 5
>>> a.__dict__
Out[9]: {'x': 3, 'y': 5}
複製程式碼

因此 __setattr__ 用在需要對例項屬性進行修改的同時,做一些額外的操作時。

但是事實上這個方法並不是那麼好用,它有很多坑,比如:

class A:
    def __init__(self):
        self.x = 3

    def __setattr__(self, name, value):
        print('set {} to {}'.format(name, value))
        setattr(self, name, value) # 換成這個
複製程式碼

這個類只要例項化就會將直譯器幹掉。因為 setattr 相當於執行了 self.name = value,但是這一賦值操作就又會觸發 __setattr__,這就形成遞迴了。由於沒有退出條件,遞迴達到極限後,直譯器退出。

謹慎使用吧。

__delattr__

當刪除例項的屬性時,會呼叫該方法。

class A:
    def __init__(self):
        self.x = 3
    def __delattr__(self, name):
        print('u cannot delete property'.format(name))
        
a = A()
del a.x
u cannot delete property
複製程式碼

它用在保護例項屬性不被刪除。

__getattribute__

只要類中定義了它,那麼訪問例項化物件中的任何屬性和方法都會呼叫該方法,殺傷力巨大。

>>> class A:
...     NAME = 'A'
... 
...     def __init__(self):
...         self.x = 3
... 
...     def __getattribute__(self, item):
...         return 'hehe'
... 
...     def method(self):
...         print('method')
...         
>>> a = A()
>>> a.x
Out[18]: 'hehe'
>>> a.method
Out[19]: 'hehe'
>>> a.NAME
Out[20]: 'hehe'
複製程式碼

因此例項化物件成員查詢順序為:

__getattribute__ -> __dict__ -> __class__.__dict__ -> __getattr__
複製程式碼

這玩意基本不會用到。

__get__

詳見描述器。

__set__

詳見描述器。

__getitem__

這個方法是用來在物件後面使用中括號的。我們之所以能夠通過在字典後面加中括號獲取字典裡面 key 對應的值,就是因為 dict 這個類中使用了 __getitem__ 方法。

class Foo:
    def __getitem__(self, item): # 接收中括號中的引數
        print(item)


obj = Foo()
obj['hehe']
複製程式碼

執行後輸出hehe。中括號中現在可以輸入內容了,但是如果使用序列的切片操作呢?python2 中會呼叫 __getslice__,但是 python3 中仍然呼叫 __getitem__

>>> class Foo:
    def __getitem__(self, item):
        print(item, type(item))

obj = Foo()
obj[1:4:2]
slice(1, 4, 2) <class 'slice'>
複製程式碼

當我們往中括號中傳遞切片的語法時,它會先呼叫slice這個類,然後將這個類傳遞給 __getitem__

但是卻無法跟字典一樣進行賦值,如果想賦值,可以使用下面的方法。

__setitem__

class Foo:
    def __setitem__(self, key, value):
        print(key, value)


obj = Foo()
obj['name'] = 'lisi'
複製程式碼

這就可以賦值了。當我們使用切片賦值時,python2 中會呼叫 __setslice__ 方法,但是 python3 中還是呼叫 __setitem__

>>> class Foo:
    def __setitem__(self, key, value):
        print(key, value, type(key), type(value))

obj = Foo()
obj[1:4:2] = [11, 22, 33]
slice(1, 4, 2) [11, 22, 33] <class 'slice'> <class 'list'>
複製程式碼

但是不能使用 del obj['xxx'] 進行刪除。如果想刪除,使用下面的方法。

__delitem__

class Foo:
    def __delitem__(self, key):
        print(key)


obj = Foo()
del obj['name']
複製程式碼

刪除分片和上面是一樣的。

__iter__

當 for 迴圈一個物件時,實際上就是執行類中的 __iter__ 方法。也就是說如果一個物件可以被 for 進行迴圈,那麼類中就必須存在 __iter__ 方法。不存在時會報錯。

>>> class Foo:
    def __iter__(self):
        yield 1
        yield 2
        yield 3

obj = Foo()
for i in obj:
    print(i)

1
2
3
複製程式碼

__metaclass__

物件預設都是由 type 建立的,我們卻可以通過 __metaclass__ 指定該物件有什麼建立。

class Foo:
    __metaclass__ = xxx
複製程式碼

表示指定該類由 xxx 建立。

__missing__

需要傳遞進來一個 key,但是沒有傳遞時觸發。

__reversed__

反向迭代,當對物件使用 reversed() 內建方法時觸發。

class Countdown:
    def __init__(self, start):
        self.start = start

    # Forward iterator
    def __iter__(self):
        n = self.start
        while n > 0:
            yield n
            n -= 1

    # Reverse iterator
    def __reversed__(self):
        n = 1
        while n <= self.start:
            yield n
            n += 1

for rr in reversed(Countdown(30)):
    print(rr)
for rr in Countdown(30):
    print(rr)
複製程式碼

運算子過載

+, -, *, / 這樣的用於數學計算的字元就屬於運算子,而這些運算子其實是對應類中的以雙下滑先開頭雙下劃線結尾的方法。

Python 中的運算子有很多,它們分為:

  • 算術運算子
  • 比較(關係)運算子
  • 賦值運算子
  • 邏輯運算子
  • 位運算子
  • 成員運算子
  • 身份運算子
  • 運算子優先順序

Python 中“身份運算子”、“邏輯運算子”和“賦值運算子”之外的所有運算子都可以過載,那運算子對應的類方法是什麼呢?我們知道 int 型別支援所有的算術運算,因此我們 help 它一下就知道大多數的運算子對應哪些方法了。而成員運算子就可以找 list 類。

算術運算:

+ -> __add__
- -> __sub__
* -> __mul__
/ -> __truediv__
% -> __mod__
複製程式碼

位運算:

& -> __and__
| -> __or__
複製程式碼

比較:

> -> __gt__
< -> __lt__
<= -> __le__
!= -> __ne__
== -> __eq__
複製程式碼

成員:

in -> __contains__
索引取值 -> __getitem__
複製程式碼

運算子過載就是我們重寫了一個類的運算子方法。運算子方法在類建立的那一刻就從 object 中繼承了,根據類繼承的原則,我們在類中重新定義運算子方法,自然就會覆蓋父類中的方法。

過載加法

先定義一個類:

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

>>> a = Point(2, 4)
>>> b = Point(3, 5)
>>> a + b
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-7-f96fb8f649b6>", line 1, in <module>
    a + b
TypeError: unsupported operand type(s) for +: 'Point' and 'Point'
複製程式碼

很顯然 a 和 b 並不能相加,但是我們可以定義一個方法讓它們實現相加。

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    # 定義一個 add 方法
    def add(self, other):
        return Point(self.x + other.x, self.y + other.y)

>>> a = Point(2, 4)
>>> b = Point(3, 5)
>>> c = a.add(b)
>>> c.x
Out[6]: 5
複製程式碼

通過一個 add 方法,我們實現了它們的相加功能。但是,我們還是習慣使用加號,事實上,我們只要改下函式名就可以使用 + 進行運算了。

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)
複製程式碼

很顯然 + 就是呼叫類的 __add__ 方法,因為我們只要加入這個方法就能夠實現加法操作。

修改運算子

我們先過載減法的運算子:

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

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Point(self.x - other.x, self.y - other.y)
複製程式碼

然後將加法的預設操作改為減法的:

Point.__add__ = lambda self, value: self - value
複製程式碼

這樣一來,我們執行加法操作,實際上執行的卻是減法:

>>> (Point(8, 9) + Point(2, 4)).x
Out[34]: 6
複製程式碼

但是 Python 限制不能對預設型別這麼做。

>>> int.__add__ = lambda self, value: self - value
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-35-77f6fd9e3d43>", line 1, in <module>
    int.__add__ = lambda self, value: self - value
TypeError: can't set attributes of built-in/extension type 'int'
複製程式碼

因此不要過度使用運算子過載。

上下文管理

在開啟檔案的時候,我們可以使用 with 語法,只要出了這個程式碼塊,那麼檔案會自動關閉,它實際上就是使用了上下文管理。open 方法裡面實現了 __enter____exit__ 這兩個方法,而存在這兩個方法的物件就是支援上下文管理的物件。

我們定義一個類:

>>> class Context:
...     def __enter__(self):
...         print('enter context')
... 
...     def __exit__(self, *args, **kwargs):
...         print('exit context')
...         
>>> with Context():
...     print('do somethings')
...     
enter context
do somethings
exit context
複製程式碼

一個支援上下文管理的物件就可以通過 with 語句進行管理,在執行 with 程式碼塊中的內容之前(__enter__)和之後(__exit__)會做些事情。

即使 with 語句塊中丟擲異常,__enter____exit__ 仍然會執行,因此上下文管理是安全的。

>>> with Context():
...     raise Exception # 直接丟擲異常
... 
enter context
exit context
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-4-63ba5aff5acc>", line 2, in <module>
    raise Exception
Exception
複製程式碼

即使在直譯器退出的情況下,__exit__ 仍然執行:

import sys
with Context():
    sys.exit()
複製程式碼

with 語法還支援 as 子句,它會獲取 __enter__ 的返回值,並將其賦值給 as 後面的變數:

>>> class Context:
...     def __enter__(self):
...         print('enter, context')
...         return 'hehe'
... 
...     def __exit__(self, *args, **kwargs):
...         print('exit context')
... 
... with Context() as c:
...     print(c)
... 
enter, context
hehe
exit context
複製程式碼

__enter__ 除了 self 之外,不接收任何引數,或者說它接受引數沒有意義。__exit__ 的返回值沒有辦法獲取到,但是如果 with 語句塊中丟擲異常,__exit__ 返回 False 時,會向上丟擲異常;返回 True 則會遮蔽異常。

__exit__ 可以接受引數,不過它的引數都是和異常相關的。當 with 程式碼塊中丟擲異常時,該異常的資訊就會被 __exit__ 所獲取。其中第一個引數是異常的型別、第二個就是這個異常的例項、第三個則是 traceback 物件。對於 with 程式碼塊中的異常,我們只能獲取異常資訊,而無法捕獲。事實上當我們定義 __exit__ 方法時,IDE 自動會補全為:

def __exit__(self, exc_type, exc_val, exc_tb):
複製程式碼

上下文管理的使用場景:凡是要在程式碼塊前後插入程式碼的場景,這點和裝飾器類似。

  • 資源管理類:申請和回收,包括開啟檔案、網路連線、資料庫連線等;
  • 許可權驗證。

contextlib

如果只想實現上下文管理而不想定義一個類的話,Python 提供了現成的東西:

import contextlib

@contextlib.contextmanager
def context():
    print('enter context') # 初始化部分
    try:
        yield 'hehe' # 相當於 __enter 的返回值
    finally:
        print('exit context') # 清理部分

with context as c:
    print(c)
複製程式碼

如果業務邏輯簡單的話,直接使用這種方式就可以了;但是如果業務複雜的話,還是使用類來的直接。

with巢狀

首先定義一個支援上下文管理的類:

from socket import socket, AF_INET, SOCK_STREAM

class LazyConnection:
    def __init__(self, address, family=AF_INET, type=SOCK_STREAM):
        self.address = address
        self.family = family
        self.type = type
        self.sock = None

    def __enter__(self):
        if self.sock is not None:
            raise RuntimeError('Already connected')
        self.sock = socket(self.family, self.type)
        self.sock.connect(self.address)
        return self.sock

    def __exit__(self, exc_ty, exc_val, tb):
        self.sock.close()
        self.sock = None
複製程式碼

有一個細節問題就是 LazyConnection 類是否允許多個 with 語句來巢狀使用連線。 很顯然,上面的定義中一次只能允許一個 socket 連線,如果正在使用一個 socket 的時候又重複使用 with 語句, 就會產生一個異常了。不過你可以像下面這樣修改下上面的實現來解決這個問題:

from socket import socket, AF_INET, SOCK_STREAM

class LazyConnection:
    def __init__(self, address, family=AF_INET, type=SOCK_STREAM):
        self.address = address
        self.family = family
        self.type = type
        self.connections = []

    def __enter__(self):
        sock = socket(self.family, self.type)
        sock.connect(self.address)
        self.connections.append(sock)
        return sock

    def __exit__(self, exc_ty, exc_val, tb):
        self.connections.pop().close()

# Example use
from functools import partial

conn = LazyConnection(('www.python.org', 80))
with conn as s1:
    pass
    with conn as s2:
        pass
        # s1 and s2 are independent sockets
複製程式碼

在第二個版本中,LazyConnection 類可以被看做是某個連線工廠。在內部,一個列表被用來構造一個棧。每次 __enter__() 方法執行的時候,它複製建立一個新的連線並將其加入到棧裡面。__exit__() 方法簡單的從棧中彈出最後一個連線並關閉它。這裡稍微有點難理解,不過它能允許巢狀使用 with 語句建立多個連線,就如上面演示的那樣。

在需要管理一些資源比如檔案、網路連線和鎖的程式設計環境中,使用上下文管理器是很普遍的。這些資源的一個主要特徵是它們必須被手動的關閉或釋放來確保程式的正確執行。例如,如果你請求了一個鎖,那麼你必須確保之後釋放了它,否則就可能產生死鎖。通過實現 __enter__()__exit__() 方法並使用 with 語句可以很容易的避免這些問題,因為 __exit__() 方法可以讓你無需擔心這些了。

用類來寫裝飾器

前面都是使用函式寫裝飾器,但是由於類中存在 __call__ 方法,因此讓通過類寫裝飾器成為了現實。前面也調到過,使用類來寫裝飾器的好處在於,我們可以在類中定義很多的方法,這樣就容易的實現邏輯拆分了,這在定義複雜的裝飾器的時候很好用。

這裡使用一個簡單的類裝飾器作為例子,用來統計一個函式的執行時間。

import datetime
from functools import wraps

class Timeit:
 # 當類要作為裝飾器的時候,init 只能接受被裝飾的函式這一個引數
    def __init__(self, fn=None): 
        wraps(fn)(self)

    # 作為裝飾器還得有一個 call 方法,讓其物件可呼叫
    def __call__(self, *args, **kwargs):
        start = datetime.datetime.now()
        ret = self.__wrapped__(*args, **kwargs)
        cost = datetime.datetime.now() - start
        print(cost)
        return ret

    def __enter__(self):
        self.start = datetime.datetime.now()

    def __exit__(self, exc_type, exc_val, exc_tb):
        cost = datetime.datetime.now()
        print(cost)

@Timeit
def add(x, y):
    x + y

add(2, 4)
複製程式碼

下面的兩個方法是上下文管理用的,作用是上這個類不僅可以作為裝飾器來計算函式的執行時間,還能夠通過 with 語句統計程式碼塊的執行時間。

wraps 是 functools 模組提供的功能,它是一個柯里化函式。它的一個引數是包裝的函式,第二個引數是被包裝的函式。在這裡包裝的函式就是被裝飾的函式,而被包裝的函式就是存在 __call__ 方法的物件本身了。wraps 會給被包裝函式增加一個 __wrapped__ 的屬性,實際上就是包裝的函式 fn。事實上我們直接呼叫 fn 也是一樣的。

反射

所謂的反射指的是執行時獲取類的資訊。事實上,我們已經接觸了一些反射相關的東西了,比如例項物件的 __dict__ 就是反射的一種體現。

前面講到了,我們可以通過物件的 __dict__ 根據屬性名稱來獲得屬性值:

>>> class Point:
...     def __init__(self, x, y):
...         self.x = x
...         self.y = y
... 
...     def print(self):
...         print(self.x, self.y)
...         
>>> a = Point(2, 5)
>>> a.__dict__['x']
Out[13]: 2
複製程式碼

但是由於 __dict__ 中沒有方法,因此我們是無法這種方式來獲取方法的。這時就輪到 getattr 登場了,它接收三個引數,分別為物件、成員名稱和預設值。

>>> getattr(a, 'print')()
2 5
複製程式碼

它不光可以獲取方法,也能獲取屬性:

>>> getattr(a, 'x')
Out[15]: 2
複製程式碼

由此,我們也能知道 setattr 和 hasattr 的用法了。

>>> setattr(a, 'z', 'hehe')
>>> a.z
Out[17]: 'hehe'
複製程式碼

setattr 的物件是例項,如果想給例項動態的增加方法首先要將函式轉換成方法,轉化的方法是 type.MethodType。

import types

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

    def print(self):
        print(self.x, self.y)

def mm(self):
    print(self.x)

p = Point(2, 4)
setattr(p, 'mm', types.MethodType(mm, p))
p.mm()
複製程式碼

在描述器中會提到 types.MethodType 是如何實現的。但是,基本上沒有動態給例項增加方法的需求。setattr 一般用來修改例項中已經存在的屬性,不到萬不得已,是不會給例項增減屬性的。

getattr 和 setattr 是相互對立的,一個是獲取一個是設定。hasattr 則用來判斷物件中是否存在特定的成員,它返回一個布林值。

這三個 *attr 就構成了反射,以下是它應用的一個小示例:

class Command:
    def cmd1(self):
        print('cmd1')

    def cmd2(self):
        print('cmd2')

    def run(self):
        while True:
            cmd = input('>>> ')
            if cmd == 'quit':
                return
            getattr(self, cmd, lambda :print('not found cmd {}'.format(cmd)))()

cmd = Command()
cmd.run()
複製程式碼

run 這個函式我們可以始終不變,如果想新增功能就定義函式,然後外部就可以通過字串的方式直接呼叫這個方法了。

這只是一個小而簡單的例子,在 RPC 的使用場景中,基本都會用到反射。

描述器

當一個類成員實現了 __get____set__ 方法之後,訪問這個類成員會呼叫 __get__ 方法,對這個類變數賦值會呼叫 __set__ 方法。對於實現了這兩種方法的類變數,我們稱之為描述器。

描述器是一個類,實現了 __get____set____delete__ 中一個或多個方法。

示例:

class Int:
    def __init__(self, name):
        self.name = name
        self.data = {}

    def __get__(self, instance, owner):
        print('get {}'.format(self.name))
        if instance is not None:
            return self.data[instance]
        return self

    def __set__(self, instance, value):
        self.data[instance] = value

    def __str__(self):
        return 'Int'

    def __repr__(self):
        return 'Int'

class A:
    val = Int('val') # 很顯然 val 是類變數
    def __init__(self):
        self.val = 3

>>> a = A() 
>>> a.val
get val
Out[5]: 3
>>> a.__dict__
Out[6]: {'val': 3}
複製程式碼

當對 A 進行例項化的時候,首先會執行 A 中的初始化方法,由於初始化中有賦值操作,因此裡面的賦值操作不會執行,而是呼叫 Int 類中的 __set__ 方法(它接收的 instance 是 A 的例項化物件,value 是賦的值),並且在 __set__ 方法執行完畢後,繼續執行初始化操作。而當我們對 a 中的 val 進行訪問時,會呼叫 Int 中的 __get__ 方法,它會接收兩個引數,instance 是 A 的例項化物件,cls 為 A 這個類本身。它是自動傳遞的。

前面提到過例項成員的查詢順序,最先查詢的是類本身的 __dict__。但是下面卻不是這樣的:

>>> a.__dict__['val'] = 5
>>> a.val
get val
Out[15]: 3 # 結果還是 3
複製程式碼

這是因為帶 __set____delete__ 方法的描述器會提升優先順序到 __dict__ 之前。

>>> class Desc:
...     def __get__(self, instance, owner):
...         pass
... 
...     def __delete__(self, instance):
...         pass
... 
... 
... class A:
...     x = Desc()
...     
>>> a = A()
>>> a.x = 3
Traceback (most recent call last):
  File "/usr/local/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-7-0a024de0ab56>", line 1, in <module>
    a.x = 3
AttributeError: __set__
複製程式碼

這就是因為 delete 提升了描述器的優先順序,因此賦值的時候會先找 set,找不到的話就報錯了。

描述器事實上是一個代理機制,當一個類變數被定義為描述器,對這個類變數的操作,將由描述器代理。其中:

  • 訪問對應 __get__
  • 賦值對應 __set__
  • 刪除對應 __delete__

注意,沒有增加方法。

即使 __set__ 會提升優先順序,但是依然遜於 __getattribute__

之前可以看到描述器中會接收引數。其中:

__get__(self, instance, owner)
__set__(self, instance, value)
__delete__(self, instance)
複製程式碼

instance 表示訪問這個類的例項,owner 表示訪問這個類的類本身。當通過類來訪問時,instance 為 None;value 表示我們所賦的值。

實現classmethod

from functools import partial

class Classmethod:
    def __init__(self, fn):
        self.fn = fn

    def __get__(self, instance, owner):
        return partial(self.fn, owner) # 執行 self.fn,並將 owner 作為引數傳遞給 self.fn

class A:
    @Classmethod # 因為 __get__ 返回的是函式,且函式可呼叫,因此它可以作為裝飾器
    def cls_method(cls):
        print(cls)

A.cls_method() # 通過類呼叫的話,__get__ 方法的 instance 為 None
A().cls_method()
複製程式碼

partial 作用是將 self.fn 的第一個引數固定為 owner。cls_method 整個函式包括引數都傳遞給 Classmethod 的建構函式,等到下面通過類訪問類成員(cls_method)的時候,呼叫 __get__ 方法。通過 partial 執行 self.fn(也就是下面的 cls_method 方法),並且將第一個引數固定為 owner,而很顯然 owner 就是 A 的例項,於是 cls = A 的例項,於是最後將這個例項列印了出來。

如果裝飾器的寫法看的有些費勁,那麼可以將之轉換為:

from functools import partial

class Classmethod:
    def __init__(self, fn):
        self.fn = fn

    def __get__(self, instance, owner):
        return partial(self.fn, owner)

class A:
    cls_method = Classmethod(lambda x: print(x))
複製程式碼

實現staticmethod

class Staticmethod:
    def __init__(self, fn):
        self.fn = fn
        
    def __get__(self, instance, owner):
        return self.fn
複製程式碼

實現property

class Property:
    def __init__(self, fget, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel

    def __get__(self, instance, owner):
        if instance is not None:
            return self.fget(instance)
        return self

    def __set__(self, instance, value):
        if callable(self.fset):
            self.fset(instance, value)
        else:
            raise AttributeError('{} cannot assignable'.format(self.fget.__name__))

    def __delete__(self, instance):
        if callable(self.fdel):
            self.fdel(instance)
        else:
            raise AttributeError('{} cannot deletable'.format(self.fget.__name__))

    def setter(self, fn):
        self.fset = fn
        return self

    def deletter(self, fn):
        self.fdel = fn
        return self


class A:
    def __init__(self):
        self.__x = 1

    @Property
    def x(self):
        return self.__x

    @x.setter
    def x(self, value):
        self.__x = value

    @x.deletter
    def x(self):
        print('cannot delete')
複製程式碼

快取

class lazyproperty:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            value = self.func(instance)
            setattr(instance, self.func.__name__, value) # 直接將類方法變成了例項方法,下次訪問就不會觸發 __get__ 方法了,這就實現了快取
            return value

import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @lazyproperty # 通過裝飾器將 area 變成了類方法,area = lazyproperty(area)
    def area(self):
        print('Computing area')
        return math.pi * self.radius ** 2

    @lazyproperty
    def perimeter(self):
        print('Computing perimeter')
        return 2 * math.pi * self.radius
複製程式碼

下面在一個互動環境中演示它的使用:

>>> c = Circle(4.0)
>>> c.radius
4.0
>>> c.area
Computing area
50.26548245743669
>>> c.area
50.26548245743669
>>> c.perimeter
Computing perimeter
25.132741228718345
>>> c.perimeter
25.132741228718345
複製程式碼

仔細觀察你會發現訊息 Computing area 和 Computing perimeter 僅僅出現一次。

由於通過裝飾器將類的動態方法變成了類方法,因此訪問 area 時就觸發了 lazyproperty 類的 __get__ 方法,只是在執行 __get__ 方法的過程中又將這個類方法變成了例項方法,因此下次再訪問這個例項方法時,就不會再觸發 __get__,也就實現了快取的效果。可以通過下面的程式碼來觀察它的執行:

>>> c = Circle(4.0)
>>> # Get instance variables
>>> vars(c)
{'radius': 4.0}

>>> # Compute area and observe variables afterward
>>> c.area
Computing area
50.26548245743669
>>> vars(c)
{'area': 50.26548245743669, 'radius': 4.0}

>>> # Notice access doesn't invoke property anymore
>>> c.area
50.26548245743669

>>> # Delete the variable and see property trigger again
>>> del c.area
>>> vars(c)
{'radius': 4.0}
>>> c.area
Computing area
50.26548245743669
複製程式碼

這種方案有一個小缺陷就是計算出的值被建立後是可以被修改的。例如:

>>> c.area
Computing area
50.26548245743669
>>> c.area = 25
>>> c.area
25****
複製程式碼

如果你擔心這個問題,那麼可以使用一種稍微沒那麼高效的實現,就像下面這樣:

def lazyproperty(func):
    name = '_lazy_' + func.__name__
    @property
    def lazy(self):
        if hasattr(self, name):
            return getattr(self, name)
        else:
            value = func(self)
            setattr(self, name, value)
            return value
    return lazy

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @lazyproperty
    def area(self): # 結果就是 area = lazyproperty(area),它的結果是一個屬性而不是方法,因為 lazy 被使用 property 了
        print('Computing area')
        return math.pi * self.radius ** 2

    @lazyproperty
    def perimeter(self):
        print('Computing perimeter')
        return 2 * math.pi * self.radius

>>> c = Circle(4.0)
>>> c.area
Computing area
50.26548245743669
複製程式碼

通過裝飾器將動態方法 area 變成了類方法 area = lazyproperty(area),並將 area 的引數 self 傳遞給了 lazy。lazyproperty 函式返回的是一個函式,一個被 property 裝飾的函式,也就是可以通過訪問屬性的方式訪問一個函式。當下面通過訪問屬性的方式執行 c.area 時,開始執行 lazy 函式。

self 中肯定沒有 name 屬性存在的,因此執行 else 子句,然後在 else 子句中在 self 中設定了 name 屬性,因此下次訪問就不會執行 else 子句了。由於 area 是一個 property 物件,因此無法對其進行賦值。

這種方案有一個缺點就是所有 get 操作都必須被定向到屬性的 getter 函式上去。這個跟之前簡單的在例項字典中查詢值的方案相比效率要低一點。

描述器總結

描述器的使用場景為:用於接管例項變數的操作。比如資料校驗,以下是驗證使用型別註解之後,輸入的型別必須是型別註解的型別。

import inspect

class Typed:
    def __init__(self, name, type):
        self.name = name
        self.type = type

    def __get__(self, instance, owner):
        if instance is not None:
            return instance.__dict__[self.name]
        return self

    def __set__(self, instance, value):
        if not isinstance(value, self.type):
            raise TypeError()
        instance.__dict__[self.name] = value

def typeassert(cls):
    params = inspect.signature(cls).parameters
    for name, param in params.items():
        if param.annotation != inspect._empty:
            setattr(cls, name, Typed(name, param.annotation))
    return cls

@typeassert
class Person:
    def __init__(self, name: str, age: int, desc):
        self.name = name
        self.age = age
        self.desc = desc

Person(11, 'tom', {})
複製程式碼

當我們需要在裝飾器中注入當前類的例項時:

import datetime
from functools import wraps
from types import MethodType

class Timeit:
    def __init__(self, fn):
        self.fn = fn
    
    def __get__(self, instance, owner):
        @wraps(self.fn)
        def wrap(*args, **kwargs):
            start = datetime.datetime.now()
            ret = self.fn(*args, **kwargs)
            cost = datetime.datetime.now() - start
            instance.send(cost)
            return ret
        
        if instance is not None:
            return MethodType(wrap, instance)
        return self

class Sender:
    def send(self, cost):
        print(cost)
    
    @Timeit
    def other(self):
        pass
複製程式碼

類的建立與銷燬

前面提到了 __init__ 方法,但是它並不會建立類,它只是執行了初始化。那類是如何建立的,self 又是哪裡來的呢?它來自於 __new__ 方法。

new 方法的定義有一定格式上的要求:

>>> class A:
...     def __new__(cls, *args, **kwargs):
...         print('new')
...         return object.__new__(cls)
... 
... A()
new
Out[2]: <__main__.A at 0x7f2247eeb9b0>
複製程式碼

object.__new__() 方法用於給類建立一個物件:

>>> object.__new__(A)
Out[3]: <__main__.A at 0x7f2246b7c240>
複製程式碼

__del__ 在物件的生命週期的結束會被呼叫:

>>> class A:
...     def __new__(cls, *args, **kwargs):
...         print('new')
...         return object.__new__(cls)
... 
...     def __init__(self):
...         print('init')
... 
...     def __del__(self):
...         print('del')
... 

>>> a = A()
new
init
>>> del a
del
複製程式碼

超程式設計

在元組的時候提到過命名元祖:

from collections import namedtuple
Person = namedtuple('Person', ['name', 'age'])
複製程式碼

它的神奇之處在於用程式碼建立了一個新的資料型別,也就是說程式碼具有寫程式碼的能力,這種能力叫做超程式設計。

我們都知道使用內建的 type 方法可以檢測一個物件的資料型別,但是它還可以用來建立一個物件。

>>> class A:
...     pass
... 
>>> type(A) # A 既是類也是物件,它的型別為 type
Out[9]: type
>>> type('Group', (), {}) # 這就動態的建立了一個物件。
Out[10]: __main__.Group
複製程式碼

通過超程式設計,我們可以控制類(注意是類而不是物件)的建立過程。而類的建立過程無非就是:

  • 成員
  • 繼承列表

改變類的建立過程無非就是更改成員以及繼承列表,這種改變可分為靜態的方式和動態的方式。首先看靜態的繼承:

>>> class DisplayMixin:
...     def display(self):
...         print(self)
... 
... class A(DisplayMixin):
...     pass
... 
... 
>>> A().display()
<__main__.A object at 0x7f2246b7c160>
複製程式碼

而超程式設計可以實現動態的方式:

>>> B = type('B', (DisplayMixin, object), {})
>>> B().display() # B 的物件也具有了 display 的方法
<__main__.B object at 0x7f2246b800f0>
複製程式碼

我們可以將之用在 if 判斷中:

if debug:
    B = type('B', (DisplayMixin, object), {})
else:
    B = type('B', (), {})
複製程式碼

如果是 debug 模式,就讓類繼承 display 方法,否則就不繼承。

當然如果覺得這麼寫麻煩的話,還可以這樣:

class DisplayMixin:
    def display(self):
        print(self)

# 這個類可以隨意的修改
class Meta(type):
    def __new__(cls, name, bases, clsdict):
        new_bases = [DisplayMixin]
        new_bases.extend(bases)
        return super().__new__(cls, name, tuple(new_bases), clsdict)

    def __init__(self, name, bases, clsdict):
        super().__init__(name, bases, clsdict)

# 然後定義類的時候這麼用即可
class C(metaclass=Meta): # metaclass 用來指定使用哪個 type 的子類來建立該類
    pass

C().display()
複製程式碼

除非你明確知道自己在幹什麼,否則不要使用超程式設計。

新式類和經典類

經典類就是 class Foo: 這種的,什麼都不繼承的。新式類就是之前總是使用的 object,從 object 類中繼承。新式類作為新的,肯定會比老的多出一些功能。那既然有新式類和經典類了,應該使用哪一種呢?用新式類。

新式類是 python 2.2 後出現的,新式類完全相容經典類,就是在經典類上面增加了新的功能。

            class A:
              ^ ^  def save(self): ...
             /   \
            /     \
           /       \
          /         \
      class B     class C:
          ^         ^  def save(self): ...
           \       /
            \     /
             \   /
              \ /
            class D
複製程式碼

類B類C都是從類A繼承的,類D則是從類BC中繼承。按理來說D會繼承C中的save方法,但是經典類中會先找B,B找不到會找A,就不會找C了。示例如下:

class A:
    def __init__(self):
        print 'This is A'
    def save(self):
        print 'save method from A'

class B(A):
    def __init__(self):
        print 'This is B'


class C(A):
    def __init__(self):
        print 'This is C'
    def save(self):
        print 'save method from C'

class D(B,C):
    def __init__(self):
        print 'This is D'

c = D()
c.save()
複製程式碼

執行結果為:

This is D
save method from A
複製程式碼

很顯然沒有繼承到C的,而是繼承了A。這是經典類中的BUG,所謂的深度優先。

但是一旦A繼承了新式類結果就是我們想要的了:

class A(object): # 只加了這點內容
    def __init__(self):
        print 'This is A'
    def save(self):
        print 'save method from A'

class B(A):
    def __init__(self):
        print 'This is B'


class C(A):
    def __init__(self):
        print 'This is C'
    def save(self):
        print 'save method from C'

class D(B,C):
    def __init__(self):
        print 'This is D'

c = D()
c.save()
複製程式碼

執行結果:

This is D
save method from C
複製程式碼

這就從C中繼承了。

補充

實現contextlib.contextmanager

from functools import wraps

class ContextManager:
    def __init__(self, fn, *args, **kwargs):
        self.gen = fn(*args, **kwargs)

    def __enter__(self):
        return next(self.gen)

    def __exit__(self, exc_type, exc_val, exc_tb):
        try:
            return next(self.gen)
        except StopIteration as e:
            return False

# 使用這個函式是為了讓使用help檢視的時候能顯示正確的文件資訊
# 包括正確的顯示函式名,而因為有了這個函式,所以類中不需要call方法了
def contextmanager(fn):
    @wraps(fn)
    def wrap(*args, **kwargs):
        return ContextManager(fn, *args, **kwargs)
    return wrap
複製程式碼

實現super

最簡單的一種形式:

from types import MethodType

class Super:
    def __init__(self, obj):
        self.type = type
        self.obj = obj

    def __getattr__(self, name):
        is_super = False
        for cls in self.type.__mro__:
            if is_super and hasattr(cls, name):
                return MethodType(getattr(cls, name), self.obj)
            if cls == self.type:
                is_super = True
        raise AttributeError()
複製程式碼

建立獨一無二的物件

object 是所有類的基類,因此我們可以呼叫它來建立一個物件,這個物件沒什麼實際用處,因為它並沒有任何有用的方法,也沒有任何例項資料。因為它沒有任何的例項字典,你甚至都不能設定任何屬性值,它唯一的作用就是來標識一個獨一無二的物件。

_no_value = object()

def spam(a, b=_no_value):
    if b is _no_value:
        print('No b value supplied')
複製程式碼

通過這個物件來判斷是否有引數傳遞進來。

實踐

cookbook 書中內容。

簡化資料結構的初始化

如果多個類都要進行初始化,且初始化的內容相同的話,就可以將這個初始化函式獨立出來,形成一個基類。

import math

class Structure1:
    # Class variable that specifies expected fields
    _fields = []

    def __init__(self, *args):
        if len(args) != len(self._fields):
            raise TypeError('Expected {} arguments'.format(len(self._fields)))
        # Set the arguments
        for name, value in zip(self._fields, args): # zip 絕對是很妙的用法
            setattr(self, name, value) # 通過 setattr 設定例項變數,很顯然這個變數是給子類設定的,因為初始化是在子類上完成的
複製程式碼

然後使你的類繼承自這個基類:

# Example class definitions
class Stock(Structure1):
    _fields = ['name', 'shares', 'price']

class Point(Structure1):
    _fields = ['x', 'y']

class Circle(Structure1):
    _fields = ['radius']

    def area(self):
        return math.pi * self.radius ** 2
複製程式碼

使用這些類的示例:

>>> s = Stock('ACME', 50, 91.1)
>>> p = Point(2, 3)
>>> c = Circle(4.5)
>>> s2 = Stock('ACME', 50)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    File "structure.py", line 6, in __init__
        raise TypeError('Expected {} arguments'.format(len(self._fields)))
TypeError: Expected 3 arguments
複製程式碼

如果還想支援關鍵字引數,可以將關鍵字引數設定為例項屬性:

class Structure2:
    _fields = []

    def __init__(self, *args, **kwargs):
        if len(args) > len(self._fields):
            raise TypeError('Expected {} arguments'.format(len(self._fields)))

        # Set all of the positional arguments
        for name, value in zip(self._fields, args):
            setattr(self, name, value)

        # Set the remaining keyword arguments
        for name in self._fields[len(args):]:
            setattr(self, name, kwargs.pop(name))

        # Check for any remaining unknown arguments
        if kwargs:
            raise TypeError('Invalid argument(s): {}'.format(','.join(kwargs))) # join 相當於對 kwargs 進行迴圈,所有值為 key
# Example use
if __name__ == '__main__':
    class Stock(Structure2):
        _fields = ['name', 'shares', 'price']

    s1 = Stock('ACME', 50, 91.1)
    s2 = Stock('ACME', 50, price=91.1)
    s3 = Stock('ACME', shares=50, price=91.1)
    # s3 = Stock('ACME', shares=50, price=91.1, aa=1)
複製程式碼

你還能將不在 _fields 中的名稱加入到屬性中去:

class Structure3:
    # Class variable that specifies expected fields
    _fields = []

    def __init__(self, *args, **kwargs):
        if len(args) != len(self._fields):
            raise TypeError('Expected {} arguments'.format(len(self._fields)))

        # Set the arguments
        for name, value in zip(self._fields, args):
            setattr(self, name, value)

        # Set the additional arguments (if any)
        extra_args = kwargs.keys() - self._fields # 計算差集,返回值為集合
        for name in extra_args:
            setattr(self, name, kwargs.pop(name))

        if kwargs:
            raise TypeError('Duplicate values for {}'.format(','.join(kwargs)))

# Example use
if __name__ == '__main__':
    class Stock(Structure3):
        _fields = ['name', 'shares', 'price']

    s1 = Stock('ACME', 50, 91.1)
    s2 = Stock('ACME', 50, 91.1, date='8/2/2012')
複製程式碼

當你需要使用大量很小的資料結構類的時候,相比手工一個個定義 __init__() 方法而已,使用這種方式可以大大簡化程式碼。在上面的實現中我們使用了 setattr() 函式類設定屬性值,你可能不想用這種方式,而是想直接更新例項字典,就像下面這樣:

class Structure:
    # Class variable that specifies expected fields
    _fields= []
    def __init__(self, *args):
        if len(args) != len(self._fields):
            raise TypeError('Expected {} arguments'.format(len(self._fields)))

        # Set the arguments (alternate)
        self.__dict__.update(zip(self._fields,args))
複製程式碼

儘管這也可以正常工作,但是當定義子類的時候問題就來了。當一個子類定義了 __slots__ 或者通過 property(或描述器)來包裝某個屬性,那麼直接訪問例項字典就不起作用了。我們上面使用 setattr() 會顯得更通用些,因為它也適用於子類情況。

這種方法唯一不好的地方就是對某些IDE而言,在顯示幫助函式時可能不太友好。比如:

>>> help(Stock)
Help on class Stock in module __main__:
class Stock(Structure)
...
| Methods inherited from Structure:
|
| __init__(self, *args, **kwargs)
|
...
複製程式碼

相關文章