2.7 泛用方法
譯者:飛龍
這一章中我們引入了複合數據類型,以及由構造器和選擇器實現的數據抽象機制。使用消息傳遞,我們就能使抽象數據類型直接擁有行為。使用對象隱喻,我們可以將數據的表示和用於操作數據的方法綁定在一起,從而使數據驅動的程序模塊化,並帶有局部狀態。
但是,我們仍然必須展示,我們的對象系統允許我們在大型程序中靈活組合不同類型的對象。點運算符的消息傳遞僅僅是一種用於使用多個對象構建組合表達式的方式。這一節中,我們會探索一些用於組合和操作不同類型對象的方式。
2.7.1 字符串轉換
我們在這一章最開始說,對象值的行為應該類似它所表達的數據,包括產生它自己的字符串表示。數據值的字符串表示在類似 Python 的交互式語言中尤其重要,其中“讀取-求值-打印”的循環需要每個值都擁有某種字符串表示形式。
字符串值為人們的信息交流提供了基礎的媒介。字符序列可以在屏幕上渲染,打印到紙上,大聲朗讀,轉換為盲文,或者以莫爾茲碼廣播。字符串對編程而言也非常基礎,因為它們可以表示 Python 表達式。對於一個對象,我們可能希望生成一個字符串,當作為 Python 表達式解釋時,求值為等價的對象。
Python 規定,所有對象都應該能夠產生兩種不同的字符串表示:一種是人類可解釋的文本,另一種是 Python 可解釋的表達式。字符串的構造函數str返回人類可讀的字符串。在可能的情況下,repr函數返回一個 Python 表達式,它可以求值為等價的對象。repr的文檔字符串解釋了這個特性:
repr(object) -> string
Return the canonical string representation of the object.
For most object types, eval(repr(object)) == object.
在表達式的值上調用repr的結果就是 Python 在交互式會話中打印的東西。
>>> 12e12
12000000000000.0
>>> print(repr(12e12))
12000000000000.0
在不存在任何可以求值為原始值的表達式的情況中,Python 會產生一個代理:
>>> repr(min)
'<built-in function min>'
str構造器通常與repr相同,但是有時會提供更加可解釋的文本表示。例如,我們可以看到str和repr對於日期的不同:
>>> from datetime import date
>>> today = date(2011, 9, 12)
>>> repr(today)
'datetime.date(2011, 9, 12)'
>>> str(today)
'2011-09-12'
repr函數的定義出現了新的挑戰:我們希望它對所有數據類型都正確應用,甚至是那些在repr實現時還不存在的類型。我們希望它像一個多態函數,可以作用於許多(多)不同形式(態)的數據。
消息傳遞提供了這個問題的解決方案:repr函數在參數上調用叫做__repr__的函數。
>>> today.__repr__()
'datetime.date(2011, 9, 12)'
通過在用戶定義的類上實現同一方法,我們就可以將repr的適用性擴展到任何我們以後創建的類。這個例子強調了消息傳遞的另一個普遍的好處:就是它提供了一種機制,用於將現有函數的職責範圍擴展到新的對象。
str構造器以類似的方式實現:它在參數上調用了叫做__str__的方法。
>>> today.__str__()
'2011-09-12'
這些多態函數是一個更普遍原則的例子:特定函數應該作用於多種數據類型。這裡舉例的消息傳遞方法僅僅是多態函數實現家族的一員。本節剩下的部分會探索一些備選方案。
2.7.2 多重表示
使用對象或函數的數據抽象是用於管理複雜性的強大工具。抽象數據類型允許我們在數據表示和用於操作數據的函數之間構造界限。但是,在大型程序中,對於程序中的某種數據類型,提及“底層表示”可能不總是有意義。首先,一個數據對象可能有多種實用的表示,而且我們可能希望設計能夠處理多重表示的系統。
為了選取一個簡單的示例,複數可以用兩種幾乎等價的方式來表示:直角座標(虛部和實部)以及極座標(模和角度)。有時直角座標形式更加合適,而有時極座標形式更加合適。複數以兩種方式表示,而操作複數的函數可以處理每種表示,這樣一個系統確實比較合理。
更重要的是,大型軟件系統工程通常由許多人設計,並花費大量時間,需求的主題隨時間而改變。在這樣的環境中,每個人都事先同意數據表示的方案是不可能的。除了隔離使用和表示的數據抽象的界限,我們需要隔離不同設計方案的界限,以及允許不同方案在一個程序中共存。進一步,由於大型程序通常通過組合已存在的模塊創建,這些模塊會單獨設計,我們需要一種慣例,讓程序員將模塊遞增地組合為大型系統。也就是說,不需要重複設計或實現這些模塊。
我們以最簡單的複數示例開始。我們會看到,消息傳遞在維持“複數”對象的抽象概念時,如何讓我們為複數的表示設計出分離的直角座標和極座標表示。我們會通過使用泛用選擇器為複數定義算數函數(add_complex,mul_complex)來完成它。泛用選擇器可訪問複數的一部分,獨立於數值表示的方式。所產生的複數系統包含兩種不同類型的抽象界限。它們隔離了高階操作和低階表示。此外,也有一個垂直的界限,它使我們能夠獨立設計替代的表示。

作為邊注,我們正在開發一個系統,它在複數上執行算數運算,作為一個簡單但不現實的使用泛用操作的例子。複數類型實際上在 Python 中已經內建了,但是這個例子中我們仍然自己實現。
就像有理數那樣,複數可以自然表示為偶對。複數集可以看做帶有兩個正交軸,實數軸和虛數軸的二維空間。根據這個觀點,複數z = x + y * i(其中i*i = -1)可以看做平面上的點,它的實數為x,虛部為y。複數加法涉及到將它們的實部和虛部相加。
對複數做乘法時,將複數以極座標表示為模和角度更加自然。兩個複數的乘積是,將一個複數按照另一個的長度作為因數拉伸,之後按照另一個的角度來旋轉它的所得結果。
所以,複數有兩種不同表示,它們適用於不同的操作。然而,從一些人編寫使用複數的程序的角度來看,數據抽象的原則表明,所有操作複數的運算都應該可用,無論計算機使用了哪個表示。
**接口。**消息傳遞並不僅僅提供用於組裝行為和數據的方式。它也允許不同的數據類型以不同方式響應相同消息。來自不同對象,產生相似行為的共享消息是抽象的有力手段。
像之前看到的那樣,抽象數據類型由構造器、選擇器和額外的行為條件定義。與之緊密相關的概念是接口,它是共享消息的集合,帶有它們含義的規定。響應__repr__和__str__特殊方法的對象都實現了通用的接口,它們可以表示為字符串。
在複數的例子中,接口需要實現由四個消息組成的算數運算:real,imag,magnitude和angle。我們可以使用這些消息實現加法和乘法。
我們擁有兩種複數的抽象數據類型,它們的構造器不同。
ComplexRI從實部和虛部構造複數。ComplexMA從模和角度構造複數。
使用這些消息和構造器,我們可以實現複數算數:
>>> def add_complex(z1, z2):
return ComplexRI(z1.real + z2.real, z1.imag + z2.imag)
>>> def mul_complex(z1, z2):
return ComplexMA(z1.magnitude * z2.magnitude, z1.angle + z2.angle)
術語“抽象數據類型”(ADT)和“接口”的關係是微妙的。ADT 包含構建複雜數據類的方式,以單元操作它們,並且可以選擇它們的組件。在面向對象系統中,ADT 對應一個類,雖然我們已經看到對象系統並不需要實現 ADT。接口是一組與含義關聯的消息,並且它可能包含選擇器,也可能不包含。概念上,ADT 描述了一類東西的完整抽象表示,而接口規定了可能在許多東西之間共享的行為。
**屬性(Property)。**我們希望交替使用複數的兩種類型,但是對於每個數值來說,儲存重複的信息比較浪費。我們希望儲存實部-虛部的表示或模-角度的表示之一。
Python 擁有一個簡單的特性,用於從零個參數的函數憑空計算屬性(Attribute)。@property裝飾器允許函數不使用標準調用表達式語法來調用。根據實部和虛部的複數實現展示了這一點。
>>> from math import atan2
>>> class ComplexRI(object):
def __init__(self, real, imag):
self.real = real
self.imag = imag
@property
def magnitude(self):
return (self.real ** 2 + self.imag ** 2) ** 0.5
@property
def angle(self):
return atan2(self.imag, self.real)
def __repr__(self):
return 'ComplexRI({0}, {1})'.format(self.real, self.imag)
第二種使用模和角度的實現提供了相同接口,因為它響應同一組消息。
>>> from math import sin, cos
>>> class ComplexMA(object):
def __init__(self, magnitude, angle):
self.magnitude = magnitude
self.angle = angle
@property
def real(self):
return self.magnitude * cos(self.angle)
@property
def imag(self):
return self.magnitude * sin(self.angle)
def __repr__(self):
return 'ComplexMA({0}, {1})'.format(self.magnitude, self.angle)
實際上,我們的add_complex和mul_complex實現並沒有完成;每個複數類可以用於任何算數函數的任何參數。對象系統不以任何方式顯式連接(例如通過繼承)這兩種複數類型,這需要給個註解。我們已經通過在兩個類之間共享一組通用的消息和接口,實現了複數抽象。
>>> from math import pi
>>> add_complex(ComplexRI(1, 2), ComplexMA(2, pi/2))
ComplexRI(1.0000000000000002, 4.0)
>>> mul_complex(ComplexRI(0, 1), ComplexRI(0, 1))
ComplexMA(1.0, 3.141592653589793)
編碼多種表示的接口擁有良好的特性。用於每個表示的類可以獨立開發;它們只需要遵循它們所共享的屬性名稱。這個接口同時是遞增的。如果另一個程序員希望向相同程序添加第三個複數表示,它們只需要使用相同屬性創建另一個類。
**特殊方法。**內建的算數運算符可以以一種和repr相同的方式擴展;它們是特殊的方法名稱,對應 Python 的算數、邏輯和序列運算的運算符。
為了使我們的代碼更加易讀,我們可能希望在執行復數加法和乘法時直接使用+和*運算符。將下列方法添加到兩個複數類中,這會讓這些運算符,以及opertor模塊中的add和mul函數可用。
>>> ComplexRI.__add__ = lambda self, other: add_complex(self, other)
>>> ComplexMA.__add__ = lambda self, other: add_complex(self, other)
>>> ComplexRI.__mul__ = lambda self, other: mul_complex(self, other)
>>> ComplexMA.__mul__ = lambda self, other: mul_complex(self, other)
現在,我們可以對我們的自定義類使用中綴符號。
>>> ComplexRI(1, 2) + ComplexMA(2, 0)
ComplexRI(3.0, 2.0)
>>> ComplexRI(0, 1) * ComplexRI(0, 1)
ComplexMA(1.0, 3.141592653589793)
**擴展閱讀。**為了求解含有+運算符的表達式,Python 會檢查表達式的左操作數和右操作數上的特殊方法。首先,Python 會檢查左操作數的__add__方法,之後檢查右操作數的__radd__方法。如果二者之一被發現,這個方法會以另一個操作數的值作為參數調用。
在 Python 中求解含有任何類型的運算符的表達值具有相似的協議,這包括切片符號和布爾運算符。Python 文檔列出了完整的運算符的方法名稱。Dive into Python 3 的特殊方法名稱一章描述了許多用於 Python 解釋器的細節。
2.7.3 泛用函數
我們的複數實現創建了兩種數據類型,它們對於add_complex和mul_complex函數能夠互相轉換。現在我們要看看如何使用相同的概念,不僅僅定義不同表示上的泛用操作,也能用來定義不同種類、並且不共享通用結構的參數上的泛用操作。
我們到目前為止已定義的操作將不同的數據類型獨立對待。所以,存在用於加法的獨立的包,比如兩個有理數或者兩個複數。我們沒有考慮到的是,定義類型界限之間的操作很有意義,比如將複數與有理數相加。我們經歷了巨大的痛苦,引入了程序中各個部分的界限,便於讓它們可被獨立開發和理解。
我們希望以某種精確控制的方式引入跨類型的操作。便於在不嚴重違反抽象界限的情況下支持它們。在我們希望的結果之間可能有些矛盾:我們希望能夠將有理數與複數相加,也希望能夠使用泛用的add函數,正確處理所有數值類型。同時,我們希望隔離複數和有理數的細節,來維持程序的模塊化。
讓我們使用 Python 內建的對象系統重新編寫有理數的實現。像之前一樣,我們在較低層級將有理數儲存為分子和分母。
>>> from fractions import gcd
>>> class Rational(object):
def __init__(self, numer, denom):
g = gcd(numer, denom)
self.numer = numer // g
self.denom = denom // g
def __repr__(self):
return 'Rational({0}, {1})'.format(self.numer, self.denom)
這個新的實現中的有理數的加法和乘法和之前類似。
>>> def add_rational(x, y):
nx, dx = x.numer, x.denom
ny, dy = y.numer, y.denom
return Rational(nx * dy + ny * dx, dx * dy)
>>> def mul_rational(x, y):
return Rational(x.numer * y.numer, x.denom * y.denom)
**類型分發。**一種處理跨類型操作的方式是為每種可能的類型組合設計不同的函數,操作可用於這種類型。例如,我們可以擴展我們的複數實現,使其提供函數用於將複數與有理數相加。我們可以使用叫做類型分發的機制更通用地提供這個功能。
類型分發的概念是,編寫一個函數,首先檢測接受到的參數類型,之後執行適用於這種類型的代碼。Python 中,對象類型可以使用內建的type函數來檢測。
>>> def iscomplex(z):
return type(z) in (ComplexRI, ComplexMA)
>>> def isrational(z):
return type(z) == Rational
這裡,我們依賴一個事實,每個對象都知道自己的類型,並且我們可以使用Python 的type函數來獲取類型。即使type函數不可用,我們也能根據Rational,ComplexRI和ComplexMA來實現iscomplex和isrational。
現在考慮下面的add實現,它顯式檢查了兩個參數的類型。我們不會在這個例子中顯式使用 Python 的特殊方法(例如__add__)。
>>> def add_complex_and_rational(z, r):
return ComplexRI(z.real + r.numer/r.denom, z.imag)
>>> def add(z1, z2):
"""Add z1 and z2, which may be complex or rational."""
if iscomplex(z1) and iscomplex(z2):
return add_complex(z1, z2)
elif iscomplex(z1) and isrational(z2):
return add_complex_and_rational(z1, z2)
elif isrational(z1) and iscomplex(z2):
return add_complex_and_rational(z2, z1)
else:
return add_rational(z1, z2)
這個簡單的類型分發方式並不是遞增的,它使用了大量的條件語句。如果另一個數值類型包含在程序中,我們需要使用新的語句重新實現add。
我們可以創建更靈活的add實現,通過以字典實現類型分發。要想擴展add的靈活性,第一步是為我們的類創建一個tag集合,抽離兩個複數集合的實現。
>>> def type_tag(x):
return type_tag.tags[type(x)]
>>> type_tag.tags = {ComplexRI: 'com', ComplexMA: 'com', Rational: 'rat'}
下面,我們使用這些類型標籤來索引字典,字典中儲存了數值加法的不同方式。字典的鍵是類型標籤的元素,值是類型特定的加法函數。
>>> def add(z1, z2):
types = (type_tag(z1), type_tag(z2))
return add.implementations[types](z1, z2)
add函數的定義本身沒有任何功能;它完全地依賴於一個叫做add.implementations的字典去實現泛用加法。我們可以構建如下的字典。
>>> add.implementations = {}
>>> add.implementations[('com', 'com')] = add_complex
>>> add.implementations[('com', 'rat')] = add_complex_and_rational
>>> add.implementations[('rat', 'com')] = lambda x, y: add_complex_and_rational(y, x)
>>> add.implementations[('rat', 'rat')] = add_rational
這個基於字典的分發方式是遞增的,因為add.implementations和type_tag.tags總是可以擴展。任何新的數值類型可以將自己“安裝”到現存的系統中,通過向這些字典添加新的條目。
當我們向系統引入一些複雜性時,我們現在擁有了泛用、可擴展的add函數,可以處理混合類型。
>>> add(ComplexRI(1.5, 0), Rational(3, 2))
ComplexRI(3.0, 0)
>>> add(Rational(5, 3), Rational(1, 2))
Rational(13, 6)
**數據導向編程。**我們基於字典的add實現並不是特定於加法的;它不包含任何加法的直接邏輯。它只實現了加法操作,因為我們碰巧將implementations字典和函數放到一起來執行加法。
更通用的泛用算數操作版本會將任意運算符作用於任意類型,並且使用字典來儲存多種組合的實現。這個完全泛用的實現方法的方式叫做數據導向編程。在我們這裡,我們可以實現泛用加法和乘法,而不帶任何重複的邏輯。
>>> def apply(operator_name, x, y):
tags = (type_tag(x), type_tag(y))
key = (operator_name, tags)
return apply.implementations[key](x, y)
在泛用的apply函數中,鍵由操作數的名稱(例如add),和參數類型標籤的元組構造。我們下面添加了對複數和有理數的乘法支持。
>>> def mul_complex_and_rational(z, r):
return ComplexMA(z.magnitude * r.numer / r.denom, z.angle)
>>> mul_rational_and_complex = lambda r, z: mul_complex_and_rational(z, r)
>>> apply.implementations = {('mul', ('com', 'com')): mul_complex,
('mul', ('com', 'rat')): mul_complex_and_rational,
('mul', ('rat', 'com')): mul_rational_and_complex,
('mul', ('rat', 'rat')): mul_rational}
我們也可以使用字典的update方法,從add中將加法實現添加到apply。
>>> adders = add.implementations.items()
>>> apply.implementations.update({('add', tags):fn for (tags, fn) in adders})
既然已經在單一的表中支持了 8 種不同的實現,我們可以用它來更通用地操作有理數和複數。
>>> apply('add', ComplexRI(1.5, 0), Rational(3, 2))
ComplexRI(3.0, 0)
>>> apply('mul', Rational(1, 2), ComplexMA(10, 1))
ComplexMA(5.0, 1)
這個數據導向的方式管理了跨類型運算符的複雜性,但是十分麻煩。使用這個一個系統,引入新類型的開銷不僅僅是為類型編寫方法,還有實現跨類型操作的函數的構造和安裝。這個負擔比起定義類型本身的操作需要更多代碼。
當類型分發機制和數據導向編程的確能創造泛用函數的遞增實現時,它們就不能有效隔離實現的細節。獨立數值類型的實現者需要在編程跨類型操作時考慮其他類型。組合有理數和複數嚴格上並不是每種類型的範圍。在類型中制定一致的責任分工政策,在帶有多種類型和跨類型操作的系統設計中是大勢所趨。
**強制轉換。**在完全不相關的類型執行完全不相關的操作的一般情況中,實現顯式的跨類型操作,儘管可能非常麻煩,是人們所希望的最佳方案。幸運的是,我們有時可以通過利用類型系統中隱藏的額外結構來做得更好。不同的數據類通常並不是完全獨立的,可能有一些方式,一個類型的對象通過它會被看做另一種類型的對象。這個過程叫做強制轉換。例如,如果我們被要求將一個有理數和一個複數通過算數來組合,我們可以將有理數看做虛部為零的複數。通過這樣做,我們將問題轉換為兩個複數組合的問題,這可以通過add_complex和mul_complex由經典的方法處理。
通常,我們可以通過設計強制轉換函數來實現這個想法。強制轉換函數將一個類型的對象轉換為另一個類型的等價對象。這裡是一個典型的強制轉換函數,它將有理數轉換為虛部為零的複數。
>>> def rational_to_complex(x):
return ComplexRI(x.numer/x.denom, 0)
現在,我們可以定義強制轉換函數的字典。這個字典可以在更多的數值類型引入時擴展。
>>> coercions = {('rat', 'com'): rational_to_complex}
任意類型的數據對象不可能轉換為每個其它類型的對象。例如,沒有辦法將任意的複數強制轉換為有理數,所以在coercions字典中應該沒有這種轉換的實現。
使用coercions字典,我們可以編寫叫做coerce_apply的函數,它試圖將參數強制轉換為相同類型的值,之後僅僅調用運算符。coerce_apply 的實現字典不包含任何跨類型運算符的實現。
>>> def coerce_apply(operator_name, x, y):
tx, ty = type_tag(x), type_tag(y)
if tx != ty:
if (tx, ty) in coercions:
tx, x = ty, coercions[(tx, ty)](x)
elif (ty, tx) in coercions:
ty, y = tx, coercions[(ty, tx)](y)
else:
return 'No coercion possible.'
key = (operator_name, tx)
return coerce_apply.implementations[key](x, y)
coerce_apply的implementations僅僅需要一個類型標籤,因為它們假設兩個值都共享相同的類型標籤。所以,我們僅僅需要四個實現來支持複數和有理數上的泛用算數。
>>> coerce_apply.implementations = {('mul', 'com'): mul_complex,
('mul', 'rat'): mul_rational,
('add', 'com'): add_complex,
('add', 'rat'): add_rational}
就地使用這些實現,coerce_apply 可以代替apply。
>>> coerce_apply('add', ComplexRI(1.5, 0), Rational(3, 2))
ComplexRI(3.0, 0)
>>> coerce_apply('mul', Rational(1, 2), ComplexMA(10, 1))
ComplexMA(5.0, 1.0)
這個強制轉換的模式比起顯式定義跨類型運算符的方式具有優勢。雖然我們仍然需要編程強制轉換函數來關聯類型,我們僅僅需要為每對類型編寫一個函數,而不是為每個類型組合和每個泛用方法編寫不同的函數。我們所期望的是,類型間的合理轉換僅僅依賴於類型本身,而不是要調用的特定操作。
強制轉換的擴展會帶來進一步的優勢。一些更復雜的強制轉換模式並不僅僅試圖將一個類型強制轉換為另一個,而是將兩個不同類型強制轉換為第三個。想一想菱形和長方形:每個都不是另一個的特例,但是兩個都可以看做平行四邊形。另一個強制轉換的擴展是迭代的強制轉換,其中一個數據類型通過媒介類型被強制轉換為另一種。一個整數可以轉換為一個實數,通過首先轉換為有理數,接著將有理數轉換為實數。這種方式的鏈式強制轉換降低了程序所需的轉換函數總數。
雖然它具有優勢,強制轉換也有潛在的缺陷。例如,強制轉換函數在調用時會丟失信息。在我們的例子中,有理數是精確表示,但是當它們轉換為複數時會變得近似。
一些編程語言擁有內建的強制轉換函數。實際上,Python 的早期版本擁有對象上的__coerce__特殊方法。最後,內建強制轉換系統的複雜性並不能支持它的使用,所以被移除了。反之,特定的操作按需強制轉換它們的參數。運算符被實現為用戶定義類上的特殊方法,比如__add__和__mul__。這完全取決於你,取決於用戶來決定是否使用類型分發,數據導向編程,消息傳遞,或者強制轉換來在你的程序中實現泛用函數。