《流暢的Python》筆記。
本篇是“物件導向慣用方法”的第四篇,主要討論介面。本篇內容將從鴨子型別的動態協議,逐漸過渡到使介面更明確、能驗證實現是否符合規定的抽象基類(Abstract Base Class, ABC)。
1. 前言
本篇討論Python中介面的實現問題,主要內容如下:
- 補充用鴨子協議實現部分介面的一種重要方法:猴子補丁;
- 說明抽象基類的常見用途,即,實現介面時作為超類使用;
- 說明抽象基類如何檢查具體子類是否符合介面定義,以及如何使用序號產生器制宣告一個類實現了某個介面;
- 說明如何不通過子類化或註冊,也能讓抽象基類自動“識別”任何符合介面的類。
補充在正文之前:
- 在Python中,“X類物件”,“X協議”和“X介面”都是一個意思。並且,除了抽象基類,類實現或繼承的公開屬性(方法或資料屬性),包括特殊方法,都可以看做介面。
- 關於介面,還有一個很實用的補充定義:物件公開方法的子集,讓物件在系統中扮演特定的角色。
2. 猴子補丁
猴子補丁並不是Python特有,它指動態語言中,不用修改原始碼,在執行時就能對程式碼的功能進行動態的追加或變更。下面的程式碼展示了猴子補丁的用法:
# 程式碼2.1
# 在檔案中定義
class MyList:
def __init__(self, iterable):
self._data = list(iterable)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
return self._data[index]
# 下面的程式碼在控制檯執行
>>> from random import shuffle
>>> from my_list import MyList
>>> mylist = MyList(range(10))
>>> def set_item(temp, i, item):
... temp._data[i] = item
...
>>> MyList.__setitem__ = set_item
>>> shuffle(mylist)
>>> deck[:]
[6, 3, 0, 1, 5, 4, 2, 7, 9, 8]
複製程式碼
解釋:
- Python中,互動式控制檯中也支援猴子補丁;
- 要使用
random.shuffle
函式,物件必須實現__setitem__
方法,上述程式碼在執行時動態新增所需方法; - 猴子補丁很強大,但打補丁的程式碼與要打補丁的程式耦合十分緊密,而且往往要處理隱藏的部分(比如“受保護的”屬性)和沒有文件的部分。
- 上述程式碼中
set_item
函式的第一個引數並不是self
,這是想說明,每個Python方法說到底都是普通函式,把第一個引數命名為self
只是一種約定(但別隨意打破這種約定)。
這裡之所以講猴子補丁,主要是為了說明協議可以是動態的:即使物件最初沒有實現某個協議,當需要時,我們也能為它動態新增。
3. 抽象基類
介紹完動態實現介面後,現在開始討論抽象基類,它屬於靜態顯示地實現介面。
3.1 基本概要說明
有時候我們需要明確區分“抽象類”(並不是指“抽象基類”)與“介面”:以自然界為例,“抽象類”一般用於同一物種同一行為,而“介面”則用於不同物種同一行為。當然,這兩個概念有交叉的部分,某些行為既可以歸到“介面“,也可以歸到”抽象類“,而最後歸到誰就見仁見智了。但這兩個概念又有很大的相似之處,它們的實質都是:讓某些物件擁有同名的方法或屬性,但具體實現不一定相同。
Java更注重這兩者的特性,而Python、C++則更注重這兩者的共性。也因此,Java不支援多重繼承(當然,也是為了降低複雜性),用明確的介面類interface
來區分與abstract class
;而在Python和C++中,則用抽象基類充當介面。所以,在Python中,直接繼承自抽象基類,更多表明的是”要實現某種介面或協議“,而非”要新建某個具體類的子類“。
如果要測試是否繼承自抽象基類,推薦使用isinstance
和issubclass
方法,而不是is
運算。但也不要濫用這類方法,因為這種程式碼用多了說明物件導向設計得不好。
說道isinstance
,還有個與之相關的概念,相當於“鴨子型別”的強化版:
- 白鵝型別(goose typing):只要
cls
是抽象基類,即cls
的元素是abc.ABCMeta
,就可以使用isinstance(obj, cls)
。
小插曲:這是書中給出的標準定義,筆者讀到這的時候一臉懵逼。“白鵝型別”是個名詞,但這定義卻是對一個過程的描述,所以“白鵝型別”到底是個啥(這到底是翻譯的鍋還是作者的鍋)?後來谷歌了一下,再自己反覆推敲,得出如下總結:鴨子型別是指某個例項實現了某個方法,就可以說它屬於某個型別,不一定要繼承;而白鵝型別則是指能被判定成某抽象基類的子類的例項,即,能使isinstance(obj, cls)
返回True
的obj
就是白鵝型別,其中cls
是抽象基類。注意,這些子類並不一定是通過繼承而來,也可能是通過註冊而來,還可能是通過實現某些方法而來。
特別提醒:對於抽象基類(還有元類)的使用,並不建議在生產程式碼中自行定義新的抽象基類和元類。定義抽象基類和元類的工作一般由比較資深的Python程式設計師來做,適用於寫框架的程式設計師。而即便是資深Python程式設計師也不常自己定義抽象基類和元類。
3.2 標準庫中的抽象基類
從Python2.6開始,標準庫提供了抽象基類。大多數抽象基類在collections.abc
模組中定義,numbers
和io
中也有一些。
以下是collections.abc
中16個抽象基類的UML圖(關於多重繼承的內容將在以後的文章中講解):
有幾個抽象基類值得注意:
Iterable
、Container
和Sized
:各個集合類應該繼承這三個抽象基類,或者至少實現相容的協議。Iterable
通過__iter__
方法支援迭代;Container
通過__contains__
方法支援in
運算;Sized
通過__len__
方法支援len()
函式;Sequence
、Mapping
和Set
:這三個是主要的不可變集合型別,而且各自都有可變的子類,即MutableSequence
、MutableMapping
和MutableSet
。Callable
和Hashable
:從圖上可以看出,這兩個抽象基類在標準庫中沒有子類。
在numbers
包中的抽象基類的繼承關係則很簡單,都是線性的(“數字塔”)。下面5個類從左到右依次派生:
Number
,Complex
,Real
,Rational
,Integral
下面我們將自行定義一個抽象基類並繼承出它的子類。但這並不是鼓勵各位在生產程式碼中自定義抽象基類!
3.3 自定義抽象基類
我們將模擬一個隨機抽獎機,它的抽象基類是Tombola
,它的4個方法如下:
.load(...)
:抽象方法,把元素放入容器;.pick()
:抽象方法,從容器中隨機返回一個元素,並從容器中刪除該元素;.loaded()
:當容器不為空是返回True
;.inspect()
:返回一個有序元組,由容器中的現有元素構成,不修改容器的內容(容器內部元素順序不保留)。
它和它的三個子類的UML圖如下:
以下是Tombola
的定義:
# 程式碼3.1
import abc
class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
"""從可迭代物件中新增元素"""
@abc.abstractmethod
def pick(self):
"""隨機刪除元素,然後將其返回。
如果例項為空,這個方法應該丟擲LookupError,
這個異常是IndexError和KeyError的基類"""
def loaded(self): # 比較耗時,子類可重寫
"""當容器不為空時返回True"""
return bool(self.inspect())
def inspect(self): # 這只是提供一種實現方式,子類可覆蓋該方法
"""返回一個有序元組,由當前元素構成"""
items = []
while True:
try: # 之所以這麼獲取元素,是因為不知道子類如何儲存元素
items.append(self.pick())
except LookupError:
break
self.load(items)
return tuple(sorted(items))
複製程式碼
解釋及補充:
- 匯入時,Python並不會檢查抽象方法的實現,在執行時才會真正檢測;
- 如果子類並沒有實現抽象基類中所有的抽象方法,那麼這個子類依然是抽象基類;
- 抽象方法中可以有實現程式碼。即便實現了,子類也必須覆蓋抽象方法,但可以使用
super()
函式呼叫抽象方法,為它新增功能,而不是從頭開始寫; - 抽象基類中的具體方法只能依賴抽象基類定義的介面。
- 標準庫中有兩個名為
abc
的模組,一個是前面說的collections.abc
,另一個就是這裡的abc
模組。只有在新定義抽象基類的時候才用得到abc.ABC
,每個抽象基類都依賴這個類。
在abc
模組中本來還有@abstractclassmethod
,@abstractstaticmethod
和@abstractproperty
三個裝飾器,但這三個從Python3.3起被廢除了,因為這三個的功能都能在@abstractmethod
上堆疊其他裝飾器得到,比如實現@abstractclassmethod
的功能:
# 程式碼3.2
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...): pass
複製程式碼
3.4 定義子類
以下是它的兩個子類的實現程式碼:
# # 程式碼3.3
class BingoCage(Tombola): # loaded()和inspect()延用抽象基類的實現
def __init__(self, items):
self._randomizer = random.SystemRandom() # 它會呼叫os.urandom()
self._items = []
self.load(items) # 委託給load()方法實現初始載入
def load(self, items): # 必須實現抽象方法!
self._items.extend(items)
self._randomizer.shuffle(self._items)
def pick(self): # 必須實現抽象方法!
try:
return self._items.pop()
except IndexError:
raise LookupError("pick from empty BingoCage")
def __call__(self):
self.pick()
class LotteryBlower(Tombola):
def __init__(self, iterable):
self._balls = list(iterable) # 副本
def load(self, iterable):
self._balls.extend(iterable)
def pick(self):
try:
position = random.randrange(len(self._balls))
except ValueError: # 為了相容Tombola,並不是丟擲ValueError
raise LookupError("pick from empty LotteryBlower")
return self._balls.pop(position)
def loaded(self): # 覆蓋了抽象基類低效的版本
return bool(self._balls)
def inspect(self):
return tuple(sorted(self._balls))
複製程式碼
3.5 虛擬子類
上面兩個子類都是直接繼承自Tombola
,而白鵝型別有一個基本特性:即便不用繼承,也能將一個類註冊為抽象基類的虛擬子類。下面是TomboList
的實現:
# 程式碼3.4
@Tombola.register # 把TomboList註冊為Tombola的虛擬子類
class TomboList(list): # 它同時還是list的真實子類,而list其實是MutableSequence的虛擬子類
def pick(self):
if self:
position = random.randrange(len(self))
return self.pop(position)
else:
raise LookupError("pick from empty LotteryBlower")
load = list.extend # 當我看到居然這麼實現方法時,感覺自己好膚淺......
def loaded(self):
return bool(self)
def inspect(self):
return tuple(sorted(self))
# Tombola.register(TomboList) 這是register的函式呼叫版本
複製程式碼
下面是這個子類的簡單使用:
# 程式碼3.5
>>> issubclass(TomboList, Tombola)
True # TomboList是Tombola的子類
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True # TomboList的例項也是Tombola型別
>>> TomboList.__mro__
(<class 'mytest.TomboList'>, <class 'list'>, <class 'object'>)
>>> TomboList.__subclasses__()
[<class 'mytest.BingoCage'>, <class 'mytest.LotteryBlower'>]
複製程式碼
解釋及補充:
- 虛擬子類不會繼承註冊的抽象基類,而且任何時候都不會檢查它是否符合抽象基類的介面,即便在例項化時也不會檢查(如果你的虛擬子類沒有實現抽象方法,在例項化時不會報錯,但如果是繼承而來的話則會報錯),所以為了避免執行時錯誤,虛擬子類應該實現抽象基類的全部方法;
- 類的繼承關係儲存在一個特殊的類屬性
__mro__
中,即方法解析順序(Method Resolution Order)。它按順序列出類及其超類,Python則會按照這個順序搜尋方法。從上述結果可以看出,這個屬性只儲存了“真實的”超類。 __subclasses__
方法返回類的直接子類列表,不含虛擬子類;- 雖然現在
register
可以當做裝飾器用,但更常用的做法還是把它當函式使用。
3.6 另一種虛擬子類
鵝的行為有可能像鴨子。先看如下程式碼:
# 程式碼3.6
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True
複製程式碼
這裡既沒有繼承,也沒有註冊,但Struggle
依然被issubclass
判斷為abc.Sized
的子類。之所以會這樣,是因為abc.Sized
實現了一個特殊的類方法__subclasshook__
:
# # 程式碼3.7,abc.Sized的實現在 _collections_abc.py 中
class Sized(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __len__(self):
return 0
@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
# 原始碼中是 return _check_methods(C, "__len__"),這裡修改了一下
if any("__len__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented
複製程式碼
這像不像鴨子型別?只要實現了__len__
方法,這個類就是abc.Sized
的子類。
在自定義的抽象基類中並不一定要實現__subclasshook__
方法,因為即使在Python原始碼中,目前也只見到Sized
這一個抽象基類實現了__subclasshook__
方法,而且Sized
只有一個特殊方法。在決定自行實現__subclasshook__
方法之前,請想清楚你一定需要這個方法嗎?你的能力能夠保證這個方法的可靠性嗎?
4. 總結
本篇討論的話題只有一個,即“介面”。首先我們討論了鴨子型別的高度動態性,它實現的是動態協議,也是非正式介面;隨後我們藉助“白鵝型別”,使用抽象基類明確地、顯示地宣告介面,然後通過子類或註冊來實現這些介面。期間,我們自定義了一個抽象基類,並通過繼承實現了它的兩個子類,還通過註冊實現了它的一個虛擬子類。
最後,還是那句話:不要輕易自定義抽象基類,除非你想構件允許使用者擴充套件的框架。日常使用中,我們與抽象基類的聯絡應該是建立現有抽象基類的子類,或者使用現有的抽象基類註冊。自己從頭編寫新抽象基類的情況非常少。
迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~