《流暢的Python》筆記。
本篇是“物件導向慣用方法”的第二篇。前一篇講的是內建物件的結構和行為,本篇則是自定義物件。本篇繼續“Python學習之路20”,實現更多的特殊方法以讓自定義類的行為跟真正的Python物件一樣。
1. 前言
本篇要討論的內容如下,重點放在了物件的各種輸出形式上:
- 實現用於生成物件其他表示形式的內建函式(如
repr()
,bytes()
等); - 使用一個類方法實現備選構造方法;
- 擴充套件內建的
format()
函式和str.format()
方法使用的格式微語言; - 實現只讀屬性;
- 實現物件的可雜湊;
- 利用
__slots__
節省記憶體; - 如何以及何時使用
@classmethod
和@staticmethd
裝飾器; - Python的私有屬性和受保護屬性的用法、約定和侷限。
本篇將通過實現一個簡單的二維歐幾里得向量型別,來涵蓋上述內容。
不過在開始之前,我們需要補充幾個概念:
repr()
:以便於開發者理解的方式返回物件的字串表示形式,它呼叫物件的__repr__
特殊方法;str()
:以便於使用者理解的方式返回物件的字串表示形式,它呼叫物件的__str__
特殊方法;bytes()
:獲取物件的位元組序列表示形式,它呼叫物件的__bytes__
特殊方法;format()
和str.format()
:格式化輸出物件的字串表示形式,呼叫物件的__format__
特殊方法。
2. 自定義向量類Vector2d
我們希望這個類具備如下行為:
# 程式碼1
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y) # Vector2d例項的分量可直接通過例項屬性訪問,無需呼叫讀值方法
3.0 4.0
>>> x, y = v1 # 例項可拆包成變數元組
>>> x, y
(3.0, 4.0)
>>> v1 # 我們希望__repr__返回的結果類似於構造例項的原始碼
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1)) # 只是為了說明repr()返回的結果能用來生成例項
>>> v1 == v1_clone # Vector2d需支援 == 運算子
True
>>> print(v1) # 我們希望__str__方法以如下形式返回例項的字串表示
(3.0, 4.0)
>>> octets = bytes(v1) # 能夠生成位元組序列
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1) # 能夠求模
5.0
>>> bool(v1), bool(Vector2d(0, 0)) # 能進行布林運算
(True, False)
複製程式碼
Vector2d
的初始版本如下:
# 程式碼2
from array import array
import math
class Vector2d:
# 類屬性,在Vector2d例項和位元組序列之間轉換時使用
typecode = "d" # 轉換成C語言中的double型別
def __init__(self, x, y):
self.x = float(x) # 構造是就轉換成浮點數,儘早在構造階段就捕獲錯誤
self.y = float(y)
def __iter__(self): # 將Vector2d例項變為可迭代物件
return (i for i in (self.x, self.y)) # 這是生成器表示式!
def __repr__(self):
class_name = type(self).__name__ # 獲取類名,沒有采用硬編碼
# 由於Vector2d例項是可迭代物件,所以*self會把x和y提供給format函式
return "{}({!r}, {!r})".format(class_name, *self)
def __str__(self):
return str(tuple(self)) # 由可迭代物件構造元組
def __bytes__(self):
# ord()返回字元的Unicode碼位;array中的陣列的元素是double型別
return (bytes([ord(self.typecode)]) + bytes(array(self.typecode, self)))
def __eq__(self, other): # 這樣實現有缺陷,Vector(3, 4) == [3, 4]也會返回True
return tuple(self) == tuple(other) # 但這個缺陷會在後面章節修復
def __abs__(self): # 計算平方和的非負數根
return math.hypot(self.x, self.y)
def __bool__(self): # 用到了上面的__abs__來計算模,如果模為0,則是False,否則為True
return bool(abs(self))
複製程式碼
3. 備選構造方法
初版Vector2d
可將它的例項轉換成位元組序列,但卻不能從位元組序列構造Vector2d
例項,下面新增一個方法實現此功能:
# 程式碼3
class Vector2d:
-- snip --
@classmethod
def frombytes(cls, octets): # 不用傳入self引數,但要通過cls傳入類本身
typecode = chr(octets[0]) # 從第一個位元組中讀取typecode,chr()將Unicode碼位轉換成字元
# 使用傳入的octets位元組序列構建一個memoryview,然後根據typecode轉換成所需要的資料型別
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv) # 拆包轉換後的memoryview,然後構造一個Vector2d例項,並返回
複製程式碼
4. classmethod與staticmethod
程式碼3
中用到了@classmethod
裝飾器,與它相伴的還有@staticmethod
裝飾器。
從上述程式碼可以看出,classmethod
定義的是傳入類而不是傳入例項的方法,即傳入的第一個引數必須是類,而不是例項。classmethod
改變了呼叫方法的方式,但是,在實際呼叫這個方法時,我們不需要手動傳入cls
這個引數,Python會自動傳入。(按照傳統,第一個引數一般命名為cls
,當然你也可以另起名)
staticmethod
也會改變方法的呼叫方式,但第一個引數不是特殊值,既不是cls
,也不是self
,就是使用者傳入的普通引數。以下是它們的用法對比:
# 程式碼4
>>> class Demo:
... @classmethod
... def klassmeth(*args):
... return args # 返回傳入的全部引數
... @staticmethod
... def statmeth(*args):
... return args # 返回傳入的全部引數
...
>>> Demo.klassmeth()
(<class 'Demo'>,) # 不管如何呼叫Demo.klassmeth,它的第一個引數始終是Demo類自己
>>> Demo.klassmeth("spam")
(<class 'Demo'>, 'spam')
>>> Demo.statmeth()
() # Demo.statmeth的行為與普通函式類似
>>> Demo.statmeth("spam")
('spam',)
複製程式碼
classmethod
很有用,但staticmethod
一般都能找到很方便的替代方案,所以staticmethod
並不是必須的。
5. 格式化顯示
內建的format()
函式和str.format()
方法把各個型別的格式化方式委託給相應的.__format__(format_spec)
方法。format_spec
是格式說明符,它是:
-
format(my_obj, format_spec)
的第二個引數; -
也是
str.format()
方法的格式字串,{}
裡替換欄位中冒號後面的部分,例如:# 程式碼5 >>> brl = 1 / 2.43 >>> "1 BRL = {rate:0.2f} USD".format(rate=brl) # 此時 format_spec為'0.2f' 複製程式碼
其中,冒號後面的
0.2f
是格式說明符,冒號前面的rate
是欄位名稱,與格式說明符無關。格式說明符使用的表示法叫格式規範微語言(Format Specification Mini-Language)。格式規範微語言為一些內建型別提供了專門的表示程式碼,比如b
表示二進位制的int
型別;同時它還是可擴充套件的,各個類可以自行決定如何解釋format_spec
引數,比如時間的轉換格式%H:%M:%S
,就可用於datetime
型別,但用於int
型別則可能報錯。
如果類沒有定義__format__
方法,則會返回__str__
的結果,比如我們定義的Vector2d
型別就沒有定義__format__
方法,但依然可以呼叫format()
函式:
# 程式碼6
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
複製程式碼
但現在的Vector2d
在格式化顯示上還有缺陷,不能向format()
傳入格式說明符:
>>> format(v1, ".3f")
Traceback (most recent call last):
-- snip --
TypeError: non-empty format string passed to object.__format__
複製程式碼
現在我們來為它定義__format__
方法。新增自定義的格式程式碼,如果格式說明符以'p'
結尾,則以極座標的形式輸出向量,即<r, θ>
,'p'
之前的部分做正常處理;如果沒有'p'
,則按笛卡爾座標形式輸出。為此,我們還需要一個計算弧度的方法angle
:
# 程式碼7
class Vector2d:
-- snip --
def angle(self):
return math.atan2(self.y, self.x) # 弧度
def __format__(self, format_spec=""):
if format_spec.endswith("p"):
format_spec = format_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = "<{}, {}>"
else:
coords = self
outer_fmt = "({}, {})"
components = (format(c, format_spec) for c in coords)
return outer_fmt.format(*components)
複製程式碼
以下是實際示例:
# 程式碼8
>>> format(Vector2d(1, 1), "0.5fp")
'<1.41421, 0.78540>'
>>> format(Vector2d(1, 1), "0.5f")
'(1.00000, 1.00000)'
複製程式碼
6. 可雜湊的Vector2d
關於可雜湊的概念可以參考之前的文章《Python學習之路22》。
目前的Vector2d
是不可雜湊的,為此我們需要實現__hash__
特殊方法,而在此之前,我們還要讓向量不可變,即self.x
和self.y
的值不能被修改。之所以要讓向量不可變,是因為我們在計算向量的雜湊值時需要用到self.x
和self.y
的雜湊值,如果這兩個值可變,那向量的雜湊值就能隨時變化,這將不是一個可雜湊的物件。
補充:
- 在文章《Python學習之路22》中說道,使用者自定義的物件預設是可雜湊的,它的雜湊值等於
id()
的返回值。但是此處的Vector2d
卻是不可雜湊的,這是為什麼?其實,如果我們要讓自定義類變為可雜湊的,正確的做法是同時實現__hash__
和__eq__
這兩個特殊方法。當這兩個方法都沒有重寫時,自定義類的雜湊值就是id()
的返回值,此時自定義類可雜湊;當我們只重寫了__hash__
方法時,自定義類也是可雜湊的,雜湊值就是__hash__
的返回值;但是,如果只重寫了__eq__
方法,而沒有重寫__hash__
方法,此時自定義類便不可雜湊。 - 這裡再次給出可雜湊物件必須滿足的三個條件:
- 支援
hash()
函式,並且通過__hash__
方法所得到的雜湊值是不變的; - 支援通過
__eq__
方法來檢測相等性; - 若
a == b
為真,則hash(a) == hash(b)
也必須為真。
- 支援
根據官方文件,最好使用異或運算^
混合各分量的雜湊值,下面是Vector2d
的改進:
# 程式碼9
class Vector2d:
-- snip --
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property # 把方法變為屬性呼叫,相當於getter方法
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __hash__(self):
return hash(self.x) ^ hash(self.y)
-- snip --
複製程式碼
文章至此說的都是一些特殊方法,如果想到得到功能完善的物件,這些方法可能是必備的,但如果你的應用用不到這些東西,則完全沒有必要去實現這些方法,客戶並不關心你的物件是否符合Python風格。
Vector2d
暫時告一段落,現在來說一說其它比較雜的內容。
7. Python的私有屬性和"受保護的"屬性
Python不像C++、Java那樣可以用private
關鍵字來建立私有屬性,但在Python中,可以以雙下劃線開頭來命名屬性以實現"私有"屬性,但是這種屬性會發生名稱改寫(name mangling):Python會在這樣的屬性前面加上一個下劃線和類名,然後再存入例項的__dict__
屬性中,以最新的Vector2d
為例:
# 程式碼10
>>> v1 = Vector2d(1, 2)
>>> v1.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0}
複製程式碼
當屬性以雙下劃線開頭時,其實是告訴別的程式設計師,不要直接訪問這個屬性,它是私有的。名稱改寫的目的是避免意外訪問,而不能防止故意訪問。只要你知道規則,這些屬性一樣可以訪問。
還有以單下劃線開頭的屬性,這種屬性在Python的官方文件的某個角落裡被稱為了"受保護的"屬性,但Python不會對這種屬性做特殊處理,這只是一種約定俗成的規矩,告訴別的程式設計師不要試圖從外部訪問這些屬性。這種命名方式很常見,但其實很少有人把這種屬性叫做"受保護的"屬性。
還是那句話,Python中所有的屬性都是公有的,Python沒有不能訪問的屬性!這些規則並不能阻止你有意訪問這些屬性,一切都看你遵不遵守上面這些"不成文"的規則了。
8. 覆蓋類屬性
這裡首先需要區分兩個概念,類屬性與例項屬性:
- 類屬性屬於整個類,該類的所有例項都能訪問這個屬性,可以動態繫結類屬性,動態繫結的類屬性所有例項也都可以訪問,即類屬性的作用域是整個類。可以按
Vector2d
中定義typecode
的方式來定義類屬性,即直接在class
中定義屬性,而不是在__init__
中; - 例項屬性只屬於某個例項物件,例項也能動態繫結屬性。例項屬性只能這個例項自己訪問,即例項屬性的作用域是類物件作用域。例項屬性需要和
self
繫結,self
指向的是例項,而不是類。
Python有個很獨特的特性:類屬性可用於為例項屬性提供預設值。
Vector2d
中有個typecode
類屬性,注意到,我們在__bytes__
方法中通過self.typecode
兩次用到了它,這裡明明是通過self
呼叫例項屬性,可Vector2d
的例項並沒有這個屬性。self.typecode
其實獲取的是Vector2d.typecode
類屬性的值,而至於怎麼從例項屬性跳到類屬性的,以後有機會單獨用一篇文章來講。
補充:證明例項沒有typecode
屬性
# 程式碼11
>>> v = Vector2d(1, 2)
>>> v.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0} # 例項中並沒有typecode屬性
複製程式碼
如果為不存在的例項屬性賦值,則會新建該例項屬性。假如我們為typecode
例項屬性賦值,同名類屬性不會受到影響,但會被例項屬性給覆蓋掉(類似於之前在函式閉包中講的區域性變數和全域性變數的區別)。藉助這一特性,可以為各個例項的typecode
屬性定製不同的值,比如在生成位元組序列時,將例項轉換成4位元組的單精度浮點數:
# 程式碼12
>>> v1 = Vector2d(1.1, 2.2)
>>> dumpd = bytes(v1) # 按雙精度轉換
>>> dumpd
b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
>>> len(dumpd)
17
>>> v1.typecode = "f"
>>> dumpf = bytes(v1) # 按單精度轉換
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@' # 明白為什麼要在位元組序列前加上typecode的值了嗎?為了支援不同格式。
>>> len(dumpf)
9
>>> Vector2d.typecode
'd'
複製程式碼
如果想要修改類屬性的值,必須直接在類上修改,不能通過例項修改。如果想修改所有例項的typecode
屬性的預設值,可以這麼做:
# 程式碼13
Vector2d.typecode = "f"
複製程式碼
然而有種方式更符合Python風格,而且效果持久,也更有針對性。通過繼承的方式修改類屬性,生成專門的子類。Django基於類的檢視就大量使用了這個技術:
# 程式碼14
>>> class ShortVector2d(Vector2d):
... typecode = "f" # 只修改這一處
...
>>> sv = ShortVector2d(1/11, 1/27)
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035) # 沒有硬編碼class_name的原因
>>> len(bytes(sv))
9
複製程式碼
9. __slots__類屬性
預設情況下,Python在各個例項的__dict__
屬性中以對映型別儲存例項屬性。正如《Python學習之路22》中所述,為了使用底層的雜湊表提升訪問速度,字典會消耗大量記憶體。如果要處理數百萬個屬性不多的例項,其實可以通過__slots__
類屬性來節省大量記憶體。做法是讓直譯器用類似元組的結構儲存例項屬性,而不是字典。
具體用法是,在類中建立這個__slots__
類屬性,並把它的值設為一個可迭代物件,其中的元素是其餘例項屬性的字串表示。比如我們將之前定義的Vector2d
改為__slots__
版本:
# 程式碼15
class Vector2d:
__slots__ = ("__x", "__y")
typecode = "d" # 其餘保持不變
-- snip --
複製程式碼
試驗表明,建立一千萬個之前版本的Vector2d
例項,記憶體用量高達1.5GB,而__slots__
版本的Vector2d
的記憶體用量不到700MB,並且速度也比之前的版本快。
但__slots__
也有一些需要注意的點:
- 使用
__slots__
之後,例項不能再有__slots__
中所列名稱之外的屬性,即,不能動態新增屬性;如果要使其能動態新增屬性,必須在其中加入'__dict__'
,但這麼做又違背了初衷; - 每個子類都要定義
__slots__
屬性,直譯器會忽略掉父類的__slots__
屬性; - 自定義類中預設有
__weakref__
屬性,但如果定義了__slots__
屬性,而且還要自定義類支援弱引用,則需要把'__weakref__'
加入到__slots__
中。
總之,不要濫用__slots__
屬性,也不要用它來限制使用者動態新增屬性(除非有意為之)。__slots__
在處理列表資料時最有用,例如模式固定的資料庫記錄,以及特大型資料集。然而,當遇到這類資料時,更推薦使用Numpy和Pandas等第三方庫。
10. 總結
本篇首先按照一定的要求,定義了一個Vector2d
類,重點是如果實現這個類的不同輸出形式;隨後,能從位元組序列"反編譯"成我們需要的類,我們實現了一個備選構造方法,順帶介紹了@classmethod
和@staticmethod
裝飾器;接著,我們通過重寫__format_
方法,實現了自定義格式化輸出資料;然後,通過使用@property
裝飾器,定義"私有"屬性以及重寫__hash__
方法等操作實現了這個類的可雜湊化。至此,關於Vector2d
的內容基本結束。最後,我們介紹了兩種常見型別的屬性(“私有”,“保護”),覆蓋類屬性以及如何通過__slots__
節省記憶體等問題。
本文實現了這麼多特殊方法只是為展示如何編寫標準Python物件的API,如果你的應用用不到這些內容,大可不必為了滿足Python風格而給自己增加負擔。畢竟,簡潔勝於複雜。
迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~