[TOC]
在前幾章中,我們已經討論了面向對象編程的許多定義特性。我們現在知道了面向對象設計的原則和范例,我們已經介紹了Python中面向對象編程的語法。
</b>
然而,我們不知道在實踐中如何以及何時在中使用這些原則和語法。在本章中,我們將用我們學到的知識討論一些有用的應用。我們會討論一些新的話題:
* 如何識別對象
* 再一次討論數據和行為
* 使用`property`包裝行為中的數據
* 使用行為限制數據
* 不要重復你自己的原則
* 識別重復代碼
## 將對象看作對象
這似乎是顯而易見的;在你的問題中,你通常應該在你的代碼里給不同的對象一個特殊類。我們在在前幾章中的案例研究中看到了這方面的例子;首先,我們識別問題中的對象,然后對它們的數據和行為進行建模。
</b>
識別對象是面向對象分析和編程中非常重要的任務。但是并不總是像計算一小段中的名詞數量那么簡單,就像我們一直在做的那樣。記住,對象是既有數據又有行為的東西。如果我們只處理數據,我們通常最好將其存儲在一個列表、集合、字典,或者其他一些Python數據結構(我們將會在第6章“Python數據結構”詳細介紹)。另一方面,如果我們只處理行為,但沒有存儲數據,簡單的函數更合適。
</b>
然而,對象既有數據又有行為。高效的Python程序員只使用內置數據結構,除非(或直到)有明顯定義一個類的需要。如果類并沒有幫助我們更好的組織代碼,就沒有理由增加額外的抽象層次。另一方面,“明顯的”需求并不總是不言自明的。
</b>
我們通常可以通過將數據存儲在幾個變量中來開始我們的Python程序。隨著程序不斷擴展后,我們會發現我們正在傳遞一組相同的相關變量集合到一組函數中。現在是考慮將變量和函數組成一個類的時候了。如果我們正在設計一個為二維空間多邊形進行建模的程序,我們可以從把每個多邊形表示為一個點集列表開始。這些點將被建模為描述該點位置的二元組(x,y)。這些都是數據,存儲在一組嵌套的數據結構中(這里是元組列表):
```
square = [(1,1), (1,2), (2,2), (2,1)]
```
現在,如果我們想計算多邊形周邊的距離,我們只需將兩點之間的距離相加。為此,我們還需要一個函數來計算兩點之間的距離。這里有兩個這樣的函數:
```
import math
def distance(p1, p2):
return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
def perimeter(polygon):
perimeter = 0
points = polygon + [polygon[0]]
for i in range(len(polygon)):
perimeter += distance(points[i], points[i+1])
return perimeter
```
現在,作為面向對象的程序員,我們清楚地認識到一個多邊形`polygon`類可以封裝點(數據)列表和周長`perimeter`函數(行為)。此外,像我們在第2章“Python中的對象”中定義的點`point`類,可以封裝x和y坐標以及距離`distance`方法。問題是:這樣做有價值嗎?
</b>
對于之前的代碼,可能是,也可能不是。使用我們剛剛學會面向對象原則的那點兒經驗,我們可以編寫一個面向對象的版本。讓我們比較一下:
```
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance(self, p2):
return math.sqrt((self.x-p2.x)**2 + (self.y-p2.y)**2)
class Polygon:
def __init__(self):
self.vertices = []
def add_point(self, point):
self.vertices.append((point))
def perimeter(self):
perimeter = 0
points = self.vertices + [self.vertices[0]]
for i in range(len(self.vertices)):
perimeter += points[i].distance(points[i+1])
return perimeter
```
從高亮部分(這里沒法高亮,囧)可以看出,這里的代碼是我們早期版本的兩倍,盡管我們可以認為`add_point`方法不是絕對必要的。
</b>
現在,為了更好地理解差異,讓我們比較一下這兩個版本的API。
以下是如何使用面向對象的代碼計算正方形的周長:
```
>>> square = Polygon()
>>> square.add_point(Point(1,1))
>>> square.add_point(Point(1,2))
>>> square.add_point(Point(2,2))
>>> square.add_point(Point(2,1))
>>> square.perimeter()
4.0
```
你可能會認為,這相當簡潔易讀,但是讓我們把它與基于函數的代碼進行比較:
```
>>> square = [(1,1), (1,2), (2,2), (2,1)]
>>> perimeter(square)
4.0
```
嗯,也許面向對象的API沒有那么緊湊!我也會說,我認為這比函數示例更容易閱讀:我們如何知道元組在函數中的意義?我們如何記住應該將哪種對象(二元組列表?這不是直覺!)傳遞到周長`perimeter`函數?我們需要大量的文檔來解釋應該如何使用這些函數。
</b>
相比之下,面向對象的代碼相對來說是自我說明的,我們只需要查看方法列表及其參數,就可以了解對象作用和如何使用它們。當我們為函數版本編寫所有文檔時,它可能比面向對象的代碼長。
</b>
最后,代碼長度不是代碼復雜性的好指標。一些程序員沉迷于復雜的“一句話”代碼,希望一句話就能完成驚人的工作量。這可能是一個有趣的練習,但結果往往是不可讀的,甚至原作者第二天就不記得昨天寫的是什么了。最小化代碼量通常可以使程序更容易閱讀,但不要盲目地濫用這種情況。
</b>
幸運的是,這種權衡是沒有必要的。我們可以制作與函數實現一樣易于使用的面向對象的多邊形`Polygon`API。我們所要做的就是改變我們的多邊形`Polygon`類,以便可以用多個點構造它。讓我們給它一個接受點`Point`對象列表的初始化方法。事實上,讓我們允許它接受元組,如果需要,我們也可以自己構建點對象:
```
def __init__(self, points=None):
points = points if points else []
self.vertices = []
for point in points:
if isinstance(point, tuple):
point = Point(*point)
self.vertices.append(point)
```
該初始化函數遍歷列表,并確保任何元組都被轉換為點。如果對象不是元組,我們就保持不變,假設它是點對象,或者可以像點對象一樣工作的未知鴨子類型的對象。
</b>
然而,在這個代碼上,面向對象和更面向數據之間沒有明顯的贏家。他們都做同樣的事情。如果我們有新的接受多邊形參數的函數,如面積`area(polygon)`或多邊形中心`point_in_polygon(polygon, x, y)`,面向對象代碼的好處變得越來越明顯。同樣地,如果我們向多邊形添加其他屬性,如顏色`color`或紋理`texture`,將數據封裝到一個類中更有意義。
</b>
區別是設計決策,但一般來說,越復雜的數據越有可能具有針對該數據的多種函數,使用帶有屬性和方法的類將變得更有用。
</b>
做出這個決定時,花點時間考慮如何使用這個類。如果我們只是在一個更大問題里試圖計算一個多邊形的周長,使用一個函數編寫代碼可能是最快和更容易使用的。另一方面,如果我們的程序需要以很多種方式處理大量多邊形(計算周長、面積、與其他多邊形的交點,移動或縮放它們,等等),最好選擇多才多藝的對象。
</b>
此外,注意對象之間的交互。尋找繼承關系;沒有類,繼承是不可能優雅建模的,所以一定要使用它們。尋找我們在第1章“面向對象的設計”中討論過的關聯和組合。從技術上講,組合可僅使用數據結構建模;例如,我們可以有一個字典列表保存元組值,但是創建幾類對象通常不太復雜,尤其是當存在與數據相關聯的行為時。
> 不要僅僅因為你可以使用一個對象就急于使用它,但是當你需要使用一個類時,千萬不要忘記創建一個類。
## 使用property給類中的數據添加行為
在這本書里,我們一直關注行為和數據的分離。這在面向對象編程中非常重要,但是我們即將看到,python這種區別可能非常模糊。python非常擅長模糊區別;這并不能幫助我們“跳出框框思考”。相反,它教導我們不要再想盒子了。
</b>
在我們進入細節之前,讓我們討論一些不好的面向對象理論。許多面向對象語言(Java是最臭名昭著的)教導我們永遠不要直接訪問屬性。他們堅持我們這樣訪問屬性:
```
class Color:
def __init__(self, rgb_value, name):
self._rgb_value = rgb_value
self._name = name
def set_name(self, name):
self._name = name
def get_name(self):
return self._name
```
變量用下劃線作為前綴,表明它們是私有的(其他語言實際上會迫使它們是私有的)。然后對每個變量的訪問提供獲取和設置方法。實際使用該類的方法如下:
```
>>> c = Color("#ff0000", "bright red")
>>> c.get_name()
'bright red'
>>> c.set_name("red")
>>> c.get_name()
'red'
```
這遠不如Python喜歡的直接訪問版本可讀:
```
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self.name = name
c = Color("#ff0000", "bright red")
print(c.name)
c.name = "red"
```
那么為什么會有人堅持基于方法的語法呢?他們的推理是,有一天,當一個值被設置或檢索時,我們可能想要添加額外的代碼。例如,我們可以決定緩存一個值并返回緩存的值,或者我們可能想要驗證該值是否是合適的輸入。
</b>
在代碼中,我們可以決定如下更改`set_name()`方法:
```
def set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
```
現在,在Java和類似語言中,如果我們編寫了原始代碼來直接屬性訪問,然后將其更改為類似于前面的方法訪問,我們會有一個問題:任何編寫了直接訪問屬性的代碼的人現在必須訪問該方法。如果他們堅持屬性訪問的風格,而不改變到函數調用時,他們的代碼將崩潰。這些語言的準則是我們永遠不應該讓公共成員私有化(譯注:有點小疑問,感覺應該是私有成員不應該編程公共成員)。但這不代表在Python中很有意義,因為Python沒有任何私有成員的真正概念!
</b>
Python給了我們`property`關鍵字,使方法看起來像屬性。我們因此可以編寫代碼來接成員訪問,如果我們偶爾在獲取或設置屬性的值時,需要更改一些計算實現,我們可以在不改變接口的情況下這樣做。讓我們看看它看起來怎么樣:
```
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self._name = name
def _set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
def _get_name(self):
return self._name
name = property(_get_name, _set_name)
```
如果我們從早期的非基于方法的類開始,該類設置`name`屬性為直接訪問屬性,然后我們更改代碼。我們首先將`name`屬性更改為私有`name`屬性。然后我們添加了另外兩個私有方法來獲取和設置變量,我們在設置方法中添加了驗證。
</b>
最后,我們在底部使用了`property`聲明。這就是魔力。它創造了`Color`類中一個名為`name`的新屬性,它現在取代了以前的`name`屬性。它將這個屬性設置為一個`property`,每當訪問或更改`property`時,它將調用我們剛剛創建的兩個方法。新版本的`Color`類的可以與以前的版本以完全相同的方式使用,但是它現在設置`name`屬性時進行了驗證:
```
>>> c = Color("#0000ff", "bright red")
>>> print(c.name)
bright red
>>> c.name = "red"
>>> print(c.name)
red
>>> c.name = ""
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "setting_name_property.py", line 8, in _set_name
raise Exception("Invalid Name")
Exception: Invalid Name
```
所以,如果我們以前已經編寫了訪問`name`屬性的代碼,然后更改成`property`對象,以前的代碼仍然可以工作,除非是發送空`property`值,這是我們現在希望禁止的行為。成功!
</b>
請記住,即使使用`name property`,以前的代碼也不是100%安全。人們仍然可以直接訪問`_name`屬性并將它設置為空字符串,如果他們想這樣做的話。但是如果他們訪問一個我們已經明確標記變量是私有的(下劃線表示),他們必須自己處理因此造成的后果,而不是我們。
### property細節
我們可以將`property`函數視為一個代理任何設置請求或者通過我們指定的方法訪問屬性值的返回對象。`property`關鍵字就像這樣一個對象的構造函數,該對象被設置為給定屬性的公共成員。
</b>
這個`property`構造函數實際上可以接受兩個額外的參數,一個刪除函數和一個`docstring`。`delete`函數很少在實際中使用,但是記錄一個值已經被刪除或者如果我們有理由可能否決刪除時,有一些作用。`docstring`只是一個描述該`property`的作用,與我們在第2章“Python中的對象”中討論的`docstring`沒有什么不同。如果我們不提供這個參數,`docstring`將改為從第一個參數的`docstring`中復制:`getter`方法。下面是一個愚蠢的何時調用這些方法的簡單示例:
```
class Silly:
def _get_silly(self):
print("You are getting silly")
return self._silly
def _set_silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
def _del_silly(self):
print("Whoah, you killed silly!")
del self._silly
silly = property(_get_silly, _set_silly,
_del_silly, "This is a silly property")
```
如果我們真的使用這個類,我們將獲得相應的結果:
```
>>> s = Silly()
>>> s.silly = "funny"
You are making silly funny
>>> s.silly
You are getting silly
'funny'
>>> del s.silly
Whoah, you killed silly!
```
此外,如果我們查看`Silly`類的幫助文件(通過在解釋器提示`help(silly)`),它顯示了我們`silly`屬性的自定義`docstring`:
```
Help on class Silly in module __main__:
class Silly(builtins.object)
|Data descriptors defined here:
|
|__dict__
| dictionary for instance variables (if defined)
|
|__weakref__
| list of weak references to the object (if defined)
|
|silly
| This is a silly property
```
再一次,一切都按照我們的計劃進行。實際上,`property`通常僅使用前兩個參數:`getter`和`setter`函數。如果我們想要為`property`提供`docstring`,我們可以在`getter`函數上定義它;`property`代理將它復制到自己的`docstring`中。刪除功能通常是留空,因為對象屬性很少被刪除。如果程序員試圖刪除沒有指定刪除函數的`property`,會引發異常。因此,如果有正當理由刪除我們的`property`,我們應該提供刪除功能。
### 裝飾器——創建property的另外一種方法
如果你以前從未使用過Python裝飾器,你可能想跳過這一部分,我們在第10章“Python設計模式I”還將討論裝飾器。然而,你現在不需要理解什么是`decorator`語法,它只是使`property`方法更可讀。
</b>
`property`函數可以與`decorator`語法一起使用,將`get`函數變成`property`:
```
class Foo:
@property
def foo(self):
return "bar"
```
這使得`property`函數成為修飾器,并等同于前面的`foo = property(foo)`。從可讀性的角度來看,主要區別在于,我們可以將`foo`函數的頂部將其標記為一個`property`,而不是在其被定義之后標記為`property`(我們很容易忘記這樣做)。這也意味著我們不必為了定義一個`property`,去創建帶有下劃線前綴的私有方法。
</b>
更進一步,我們可以為新`property`指定setter函數,如下所示:
```
class Foo:
@property
def foo(self):
return self._foo
@foo.setter
def foo(self, value):
self._foo = value
```
這種語法看起來很奇怪,盡管意圖很明顯。首先,我們裝飾`foo`方法作為`getter`。然后,我們裝飾第二種同名方法,通過在最初修飾的`foo`方法上設置`setter`屬性進行裝飾!這`property`函數返回一個對象;這個對象總是自帶`setter`屬性,然后可以將其作為修飾器應用于其他函數。對`get`和`set`方法使用相同的名稱不是必需的,但它確實有助于對多個方法進行分組一起訪問一個`property`方法。
</b>
我們也可以用`@foo.deleter`指定刪除函數。我們不能使用`property`修飾器指定`docstring`,所以我們需要依賴`property`復制來自初始`getter`方法的`docstring`。
</b>
下面是我們之前重寫的`Silly`類,它使用`property`作為修飾器:
```
class Silly:
@property
def silly(self):
"This is a silly property"
print("You are getting silly")
return self._silly
@silly.setter
def silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
@silly.deleter
def silly(self):
print("Whoah, you killed silly!")
del self._silly
```
這個類的操作與我們的早期版本完全相同,包括幫助文本。你可以使用任何你認為更易讀、更優雅的語法。
### 決定何時使用property
由于`property`屬性模糊了行為和數據之間的界限,我們可能會很困惑,不知該選擇哪一個。我們前面看到的示例是`property`最常見的用途之一;我們的一個類有一些數據,我們希望以后在上面添加一些行為。在決定使用`property`,還有其他因素需要考慮。
</b>
技術上,在Python中,數據、`property`和方法都是類的屬性。一個方法可調用的這一事實并不是它與其他類型屬性的區別;事實上,我們將在第7章“Python面向對象的快捷方式”中看到,我們可以創建像函數一樣調用的普通對象。我們也會發現函數和方法本身就是正常的對象。
</b>
事實上,方法只是可調用的屬性,`property`只是可定制的、可以幫助我們做出決定的屬性。方法通常應該表示動作;要做的事情用對象表示。當你調用一個方法時,甚至只有一個參數,它都會*做些*什么。方法的名子通常是動詞。
</b>
一旦確認屬性不是動作,我們需要在標準數據屬性和`property`之間進行選擇。通常,總是優先使用標準數據屬性,直到你需要以某種方式控制對該屬性的訪問,正如`property`所做的。無論哪種情況,屬性通常是名詞。屬性和`property`之間唯一的區別是當需要檢索、設置,或者刪除時,我們需要的是`property`。
</b>
讓我們看一個更現實的例子。自定義行為的一個常見需求是緩存難以計算或查找成本高的值(例如,網絡請求或數據庫查詢)。目標是將值存儲在本地以避免反復調用昂貴的計算。
</b>
我們可以使用`property`上的自定義`getter`來實現這一點。第一次值被檢索時,我們執行查找或計算。然后我們可以在本地緩存這個值,作為我們對象(或專用緩存軟件)的私有屬性,并且下次請求該值時,我們會返回存儲的數據。我們可以這樣緩存一個網頁:
```
from urllib.request import urlopen
class WebPage:
def __init__(self, url):
self.url = url
self._content = None
@property
def content(self):
if not self._content:
print("Retrieving New Page...")
self._content = urlopen(self.url).read()
return self._content
```
我們可以測試這段代碼,以確保頁面只被檢索一次:
```
>>> import time
>>> webpage = WebPage("http://ccphillips.net/")
>>> now = time.time()
>>> content1 = webpage.content
Retrieving New Page...
>>> time.time() - now
22.43316888809204
>>> now = time.time()
>>> content2 = webpage.content
>>> time.time() - now
1.9266459941864014
>>> content2 == content1
True
```
當我最初測試這段代碼和它的時候,我的衛星連接很糟糕,我第一次加載內容花了20秒。第二次,我在2秒鐘內得到了結果(這實際上就是輸入行所花費的時間)。
</b>
自定義`getter`對于需要計算的屬性也很有用,這些對象屬性基于其他對象屬性。例如,我們可能想要計算整數列表的平均值:
```
class AverageList(list):
@property
def average(self):
return sum(self) / len(self)
```
這個非常簡單的、繼承自列表`list`的類,所以我們可以免費獲得類似列表的行為。我們只需向類中添加一個`property`,很快,我們的列表就會有一個平均值:
```
>>> a = AverageList([1,2,3,4])
>>> a.average
2.5
```
當然,我們可以把它變成一種方法,但是我們應該稱之為`calculate_average()`,因為方法代表動作。但是一個叫做`average`更合適,更容易打字,也更容易閱讀。
</b>
正如我們已經看到的,自定義`setter`對于驗證很有用,但是它們也可以用于將值代理到另一個位置。例如,我們可以為`WebPage`添加一個內容`setter`,自動登錄到我們的web服務器,只要設置了值,就上傳一個新頁面。
## 管理對象
我們一直關注對象及其屬性和方法。現在,我們要看看設計更高級別的對象:管理其他對象的對象,用于將對象聯系在一起。
</b>
這些對象和我們迄今為止看到的大多數例子之間的區別是,我們的例子傾向于代表具體的想法。管理對象更像辦公室經理;他們不在地板上做真正的“可見”工作,但是沒有他們,部門之間就不會有交流,沒有人知道他們應該做什么(如果組織管理不善,就可能成為現實!譯注:職場中人看此評論一聲長嘆)。類似地,管理類的屬性傾向于指揮其他對象做“可見”的工作;這種類的行為時在合適的時間把任務委托給其他類,并在它們之間傳遞消息。
</b>
例如,我們將編寫一個程序,為存儲在壓縮的ZIP文件中的文本文件執行查找和替換操作。我們需要定義一個對象來表示ZIP文件和單個文本文件(幸運的是,我們不必編寫這些類,它們存在于Python標準庫)。經理對象將負責確保依次進行三個步驟:
1. 解壓縮壓縮文件
2. 執行查找和替換操作
3. 重新壓縮文件
類的初始化參數包括`.zip`文件名、搜索字符串和替換字符串。我們創建一個臨時目錄來存儲解壓縮后的文件,所以文件夾要干凈。Python 3.4`pathlib`庫有助于文件和目錄操作。我們將在第8章"字符串和序列化"中了解更多這個庫的使用,但是下面例子的接口應該很清楚:
```
import sys
import shutil
import zipfile
from pathlib import Path
class ZipReplace:
def __init__(self, filename, search_string, replace_string):
self.filename = filename
self.search_string = search_string
self.replace_string = replace_string
self.temp_directory = Path("unzipped-{}".format(
filename))
```
然后,我們為這三個步驟中的每一個創建一個總體的“經理”方法。這些方法將責任委托給其他方法。顯然,我們可以把三個步驟要做所有事情放在一種方法中,或者更加實際一點,把這些事情放在一個腳本上,且不需要創建對象。將這三個步驟分開有幾個優點:
* **可讀性**:每個步驟的代碼都在一個獨立的單元中,易于閱讀并理解。方法名描述了方法的作用,并且對理解正在發生的事情需要更少的額外文檔。
* **可擴展性**:如果子類想要使用壓縮的TAR文件,而不是ZIP壓縮文件,它可能覆蓋壓縮`zip`和解壓縮`unzip`方法,而不必復制`find_replace`方法。
* **分區**:外部類可以創建該類的實例,并且直接在某個文件夾上調用`find_replace`方法,而不必壓縮內容。
委托方法是下面代碼中的第一個;為完整起見,其余的方法也包括了進來:
```
def zip_find_replace(self):
self.unzip_files()
self.find_replace()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.filename) as zip:
zip.extractall(str(self.temp_directory))
def find_replace(self):
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(
self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
def zip_files(self):
with zipfile.ZipFile(self.filename, 'w') as file:
for filename in self.temp_directory.iterdir():
file.write(str(filename), filename.name)
shutil.rmtree(str(self.temp_directory))
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).zip_find_replace()
```
為簡潔起見,壓縮和解壓縮文件的代碼很少。我們當前的焦點是面向對象的設計;如果你對`zipfile`模塊的內在細節感興趣,在線參考標準庫中的文檔或者在交互式解釋器鍵入`import zipfile ; help(zipfile)`查詢。請注意,本示例僅搜索ZIP文件中的第一級文件;如果解壓縮文件夾還有其他文件夾,里面的任何文件不會被掃描。
</b>
示例中的最后兩行允許我們從命令運行程序,傳入zip文件、搜索字符串和替換字符串參數:
```
python zipsearch.py hello.zip hello hi
```
當然,這個對象不必從命令行創建;有可能從另一個模塊導入(執行批處理ZIP文件處理)或作為圖形用戶界面的一部分,甚至更高級別的管理對象,知道在哪里獲取壓縮文件(例如,從文件傳輸協議服務器檢索文件或將它們備份到外部磁盤)。
</b>
隨著程序變得越來越復雜,被建模的對象變得越來越不太像實物。`property`是其他抽象對象,方法是改變這些抽象對象狀態的動作。但是在每個對象的核心,不管有多復雜,都是一套具體的性質和明確的行為。
### 移除重復的代碼
像`ZipReplace`這樣的管理風格類中的代碼通常是非常通用的,并且可以以多種方式應用。可以使用組合或繼承將代碼保存在一個地方,從而消除重復代碼。在我們看這些例子之前,讓我們先討論一點理論。具體來說,為什么重復代碼是件壞事?
</b>
有幾個原因,但都歸結為可讀性和可維護性。當我們在寫一段與早期代碼相似的新代碼時,最簡單的要做的事情是復制舊代碼,并更改需要更改的內容(變量名稱、邏輯、注釋)以使其在新的位置工作。或者,如果我們編寫看似相似但與項目中其他地方的代碼不同的新代碼,編寫具有類似行為的新代碼通常比弄清楚如何提取重疊功能更容易。
</b>
但是一旦有人必須閱讀和理解代碼,他們就會發現重復的代碼塊,他們面臨著兩難的境地。可能有意義的代碼突然必須得搞清楚是怎么回事。這一段代碼和另一段代碼有什么不同?怎么它們是一樣的呢?在什么情況下這一段代碼應該被調用?什么時候又該調用另一個呢?你可能會說你是唯一一個閱讀代碼的人,但是如果你八個月內不碰那個代碼,它對你來說將是不可理解的,就好像給一個新程序員看一樣。當我們試圖讀取兩條相似的代碼時,我們必須理解它們為什么不同,以及它們是如何不同的。這浪費了讀者時間;代碼應該總是先被編寫成可讀的。
> 我曾經不得不試著去理解一個人的三段相同的同樣300行寫得很差的代碼副本。我用了一個月才明白三個“相同”的代碼版本實際上只是表現略有不同稅收計算。一些細微的差別是有意的,但是在一些明顯的地方有人更新了一個函數中的計算,而沒有更新另外兩個函數。代碼中微妙的、不可理解的錯誤無法計數。我最終用20行左右的易讀函數替換了所有900行。
讀取這樣的重復代碼可能很煩人,但是代碼維護更麻煩折磨。正如前面的故事所暗示的,保留兩個相似的代碼片段,可能將是一場噩夢。無論何時,只要我們更新其中一個,我們都必須記住更新另一個函數,我們必須記住多個部分是如何不同的,這樣我們才可以在編輯每個變更時修改它們。如果我們忘記同時更新這兩段代碼,我們最終都會發現非常煩人的bug,就像我們一直常說的,“明明我已經解決了,為什么它還在發生?”
</b>
結果是,閱讀或維護我們代碼的人不得不花費大量的時間,而如果我們從一開始以非重復的方式編寫代碼,情況就好很多。甚至當我們做維護的時候,我們很沮喪;我們發現自己在說,"為什么我第一次做得不對?"我們通過復制粘貼現有代碼所節省的時間,在我們第一次維護時就丟失了。代碼被讀取和修改比它寫的次數和頻率多得多。可理解的代碼應該永遠是最重要的。
</b>
這就是為什么程序員,尤其是Python程序員(他們傾向于重視優雅代碼,超過平均水平),遵循所謂的“不要重復你自己”**DIY**原則。DIY代碼是可維護的代碼。我對初級程序員的建議是永遠不要使用編輯器的復制和粘貼功能。對于中級程序員,我建議他們在點擊Ctrl + C之前三思而后行。
</b>
但是我們應該做什么來代替代碼復制呢?最簡單的解決方案通常是將代碼移動到一個函數中,該函數通過接受參數來說明任何部分的不同點。這不是一個非常面向對象的解決方案,但是它通常是最佳的。
</b>
例如,如果我們需要寫兩段代碼將一個壓縮文件解壓到兩個不同的文件目錄,我們可以很容易地編寫一個接受目錄參數的函數,這個目錄參數表示被解壓到的位置。這可能會使函數本身稍微更難讀,但是一個好的函數名和docstring可以很容易地解決這個問題。調用該函數的任何代碼都將更容易閱讀。
</b>
這當然是足夠的理論!這個故事的寓意是:永遠努力重構代碼,使其更容易閱讀,而不是編寫更容易的糟糕代碼。
### 實踐
讓我們探索兩種重用現有代碼的方法。我們已經編寫了替換ZIP文件中的文本文件的字符串的代碼,現在我們被要求縮放ZIP文件中的所有的圖像的尺寸到640 x 480。看起來我們可以使用一個非常類似的`ZipReplace`范例。第一個沖動可能是保存該文件的副本并將`find_replace`方法更改成`scale_image`方法,或類似的方法。
</b>
但是,那不酷。如果有一天我們想把`unzip`和`zip`方法改為也可以打開TAR文件呢?或者,我們可能希望為臨時文件提供一個有保證的唯一目錄。無論哪種情況,我們都必須在兩個不同的地方改變它!
</b>
我們將從演示這個問題的基于繼承的解決方案開始。首先我們將把原始的`ZipReplace`類修改成一個超類來處理泛型ZIP文件:
```
import os
import shutil
import zipfile
from pathlib import Path
class ZipProcessor:
def __init__(self, zipname):
self.zipname = zipname
self.temp_directory = Path("unzipped-{}".format(
zipname[:-4]))
def process_zip(self):
self.unzip_files()
self.process_files()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.zipname) as zip:
zip.extractall(str(self.temp_directory))
def zip_files(self):
with zipfile.ZipFile(self.zipname, 'w') as file:
for filename in self.temp_directory.iterdir():
file.write(str(filename), filename.name)
shutil.rmtree(str(self.temp_directory))
```
我們將`filename`屬性更改為`zipname`,以避免與各種方法中的`filename`局部變量混淆。這有助于代碼更易讀,即使它實際上不是設計上的改變。
</b>
我們還刪除了`__init__`初始化方法的兩個參數(`search_string`和`replace_ string`),這兩個參數被指定用于`ZipReplace`類。然后我們將`zip_find_replace`方法重命名為`process_zip` ,使它能夠調用一個(尚未確定的)`process_files`方法(替代`find_replace`方法)。這些名稱更改有助于演示新類的普遍性。請注意,我們已經刪除了`find_replace`方法;該代碼是專門用于`ZipReplace`的,在這里沒有任何意義。
</b>
這個新的`ZipProcessor`類實際上并沒有定義`process_files`方法;所以如果我們直接運行它,它會引發一個異常。因為它不是用來運行,我們直接刪除了原始腳本底部的主調用。
</b>
現在,在我們繼續我們的圖像處理應用程序之前,讓我們修復一下我們`zipsearch`類(譯注:感覺應該是` ZipReplace`)的原始版本,以使用這個父類:
```
from zip_processor import ZipProcessor
import sys
import os
class ZipReplace(ZipProcessor):
def __init__(self, filename, search_string,
replace_string):
super().__init__(filename)
self.search_string = search_string
self.replace_string = replace_string
def process_files(self):
'''對臨時目錄中的所有文件執行搜索和替換'''
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(
self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).process_zip()
```
這個代碼比原始版本稍短一點,因為它繼承了它來自父類的的ZIP處理能力。我們首先導入我們剛剛編寫的基類,隨后`ZipReplace`擴展了該基類。然后我們使用`super()`初始化父類。`find_replace`方法仍然存在,但是我們將其重命名為`process_files`,以便于父類可以從它的管理接口調用這個方法。因為這個名字和舊版本不一樣,我們添加了一個`docstring`來描述它在做什么。
</b>
現在,考慮到我們現在只有一個項目,這是相當多的工作,但在功能上與我們開始時沒有什么不同!但是做了這些之后,現在對我們來說,編寫在ZIP中對文件進行操作的其他類要容易得多,例如(假設請求的)照片縮放。此外,如果我們想改進或修復zip功能,我們可以通過改變`ZipProcessor`基類即可。這樣維護會更加有效。
</b>
看看現在創建一個利用`ZipProcessor`功能的照片縮放類。(注意:本課程需要第三方`pilleow`庫去使用`PIL`模塊。你可以用`pip install pillow`安裝它)
```
from zip_processor import ZipProcessor
import sys
from PIL import Image
class ScaleZip(ZipProcessor):
def process_files(self):
'''把文件夾中圖片縮放至640x480'''
for filename in self.temp_directory.iterdir():
im = Image.open(str(filename))
scaled = im.resize((640, 480))
scaled.save(str(filename))
if __name__ == "__main__":
ScaleZip(*sys.argv[1:4]).process_zip()
```
看這個類有多簡單!我們之前做的所有工作都有回報。我們所做的就是打開每個文件(假設它是一個圖像;如果一個文件無法打開,程序將崩潰),縮放它,并將其保存回來。ZipProcessor類負責解壓和壓縮的工作,我們無需任何額外的工作。
## 個案研究
在這個案例研究中,我們將嘗試進一步深入這個問題,“我們應該在什么時候選擇對象而不是內置類型?”,我們將建模一個文檔`Document`類,它可能用于文本編輯器或文字處理器。這個類應該具有哪些對象、函數或`property`呢?
</b>
我們可以從代表`Document`內容的`str`開始,但是在Python中,字符串不是可變的(但可以改變)。一旦定義了一個字符串`str`,它就是永遠。我們不能對這個`str`插入或者刪除一個字符,除非我們創建一個全新的字符串對象。那會留下大量字符串對象`str`占用內存,直到Python垃圾回收器清理掉它們。
</b>
因此,我們將使用一個字符列表替換字符串,這樣我們可以隨意修改字符串。此外,Document類需要知道當前指針在列表中的位置,可能還應該存儲文檔的文件名。
</b>
> 真實文本編輯器使用基于二叉樹的數據結構,稱為繩子來模擬他們的文檔內容。這本書的標題不是“高級數據結構”,所以如果您有興趣了解更多關于這個迷人的話題,你可在網上搜索繩索數據結構。
現在,它應該有什么方法呢?我們可能想做很多事情,對文本文檔執行操作,包括插入、刪除和選擇字符,剪切、復制、粘貼、選擇以及保存或關閉文檔。看起來有大量的數據和行為,所以把這些放在`Document`類中是有意義的。
</b>
一個相關的問題是:這個類應該由一堆基本的Python對象組成嗎?例如,`str`文件名、`int`指針位置和`list`字符列表?或者這些東西中的一些或全部應該單獨被特別定義為對象嗎?單獨的行和字符怎么辦,它們需要有自己的類嗎?我們會邊做邊回答這些問題,讓我們從最簡單的`Document`類開始,看看它能做什么:
```
class Document:
def __init__(self):
self.characters = []
self.cursor = 0
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor, character)
self.cursor += 1
def delete(self):
del self.characters[self.cursor]
def save(self):
with open(self.filename, 'w') as f:
f.write(''.join(self.characters))
def forward(self):
self.cursor += 1
def back(self):
self.cursor -= 1
```
這個簡單的類允許我們完全控制編輯一個基本文檔。運行看一看:
```
>>> doc = Document()
>>> doc.filename = "test_document"
>>> doc.insert('h')
>>> doc.insert('e')
>>> doc.insert('l')
>>> doc.insert('l')
>>> doc.insert('o')
>>> "".join(doc.characters)
'hello'
>>> doc.back()
>>> doc.delete()
>>> doc.insert('p')
>>> "".join(doc.characters)
'hellp'
```
看起來可以用。我們可以把鍵盤上的字母和箭頭鍵和這些上面方法聯系起來,文檔將更好地跟蹤一切。
</b>
但是如果我們想連接的不僅僅是箭頭鍵呢?如果我們還想連接呢`Home`鍵和`End`鍵呢?我們可以向`Document`類添加更多的方法,用于向前或向后搜索換行符(在Python中,換行符字符,或`\n`表示一行的結尾和新行的開頭),然后跳到它們身上,但是如果我們對每一個可能的動作都這樣做(按單詞移動,按句子移動,向上翻頁,向下翻頁,行尾,開始空白等等),這個類會很大。也許把這些方法放在單獨的對象上比較好。所以,讓我們把指針屬性變成一個對象,用于發現位置并能操縱該位置。我們可以把向前和向后的方法移動到指針類,并為`Home`鍵和`End`鍵添加更多的方法:
```
class Cursor:
def __init__(self, document):
self.document = document
self.position = 0
def forward(self):
self.position += 1
def back(self):
self.position -= 1
def home(self):
while self.document.characters[
self.position-1] != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(self.document.characters
) and self.document.characters[
self.position] != '\n':
self.position += 1
```
該類將文檔作為初始化參數,因此類方法可以訪問文檔字符列表的內容。然后它提供了簡單的方法,如前所述,用于向后和向前移動的方法,以及用于移動到`Home`和`End`位置的方法。
> 這個代碼不太安全。你很容易地越過結尾,如果你試圖在一個空文件上回到`Home`,它會崩潰。這些例子保持簡短,以使它們可讀,但并不意味著它們是無敵的!你可以改進作為練習這方面的錯誤檢查;這可能擴展你的異常處理技能。
`Document`類本身幾乎沒有變化,除了刪除兩種方法,將它們移動到了`Cursor`類(譯注:注意`save`方法,原本是用`open`的,可以省下`close`方法):
```
class Document:
def __init__(self):
self.characters = []
self.cursor = Cursor(self)
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor.position,
character)
self.cursor.forward()
def delete(self):
del self.characters[self.cursor.position]
def save(self):
f = open(self.filename, 'w')
f.write(''.join(self.characters))
f.close()
```
我們使用新對象更新訪問舊指針指向的任何內容。我們可以測試`home`方法是否真正移動到換行符:
```
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert('l')
>>> d.insert('l')
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert('w')
>>> d.insert('o')
>>> d.insert('r')
>>> d.insert('l')
>>> d.insert('d')
>>> d.cursor.home()
>>> d.insert("*")
>>> print("".join(d.characters))
hello
*world
```
現在,因為我們已經使用了很多字符串連接`join`函數(用來連接字符以便我們可以看到實際的文檔內容),我們可以在`Document`類添加一個`property
`,以便我們看到完整的字符串:
```
@property
def string(self):
return "".join(self.characters)
```
這讓我們的測試變得簡單了一點:
```
>>> print(d.string)
hello
world
```
這個框架很簡單(雖然可能有點耗時!),延伸到創建和編輯完整的明文文檔。現在,讓我們把它擴展到為富文本;可以有粗體、下劃線或斜體字符的文本。
</b>
我們有兩種方法可以處理這個問題;第一種是將“假”字符插入我們的字符列表,就像指令一樣,比如“將字符變成粗體字,直到遇到停止粗體字符”。第二種方法是給每個字符添加它應該有什么格式的信息。雖然前一種方法可能更常見,我們將實現后一種解決方案。為此,我們顯然需要一個字符類。這個類將有一個表示字符的屬性,以及三個布爾屬性,表示它是否是粗體、斜體還是下劃線。
</b>
嗯,等等!這個`Character`類會有什么方法嗎?如果沒有,也許我們應該使用Python眾多數據結構中的一種;元組或命名元組可能就足夠了。有什么我們想做的事嗎?或者對一個字符進行調用?
</b>
很明顯,我們可能想用字符做一些事情,比如刪除或復制,但是這些都是需要在文檔`Document`級別處理的事情,因為它們正在修改字符列表。有什么需要對單個字符做的事情嗎?
</b>
事實上,現在我們正在思考什么是字符`Character`類...它到底是什么呢?可以說`Character`類是字符串嗎?也許我們應該在這里使用繼承關系?然后我們可以利用字符串`str`實例附帶的眾多方法。
</b>
我們在談論什么樣的方法?開始于`startswith`,剝去`strip`,找到`find`,小寫`lower`,還有更多其他方法。這些方法中的大多數都期望在包含多個字符的字符串上工作。相反,如果`Character`是`str`子類,如果是對多字符操作,我們最好重寫`__init__`來引發異常。既然我們免費獲得的所有這些方法對我們的`Character`類來說并不真正適用,似乎我們根本不需要使用繼承。
</b>
這讓我們回到了最初的問題;`Character`應該是一個類嗎?我們可以使用`object`類一種非常重要的特殊方法,代表我們`Character`的優勢。這個方法叫做`__str__`(兩個下劃線,如`__init__`),用于字符串操作功能(如打印),`str`構造函數可以將任何類轉換為字符串。默認實現一些無聊的事情,比如打印模塊和類的名稱以及它們在內存中的地址。但是如果我們覆蓋它,我們可以讓它打印任何我們喜歡的東西。對于我們的實現,我們可以讓它用特殊字符作為前綴字符,表示它們是粗體、斜體還是下劃線。所以,我們將創建一個代表一個字符的類,如下所示:
```
class Character:
def __init__(self, character,
bold=False, italic=False, underline=False):
assert len(character) == 1
self.character = character
self.bold = bold
self.italic = italic
self.underline = underline
def __str__(self):
bold = "*" if self.bold else ''
italic = "/" if self.italic else ''
underline = "_" if self.underline else ''
return bold + italic + underline + self.character
```
這個類允許我們創建字符,當`str()`函數應用于它們時,將在字符前添加一個特殊字符前綴。這沒什么太令人興奮的。如果我們想讓`Character`和`Document`、`Cursor`類一起工作,只須做一些小的修改。在文檔`Document`類中,我們在插入`insert`方法的開始位置插入兩行代碼:
```
def insert(self, character):
if not hasattr(character, 'character'):
character = Character(character)
```
這看起來相當奇怪。它的基本目的是檢查傳入的字符是否是字符`Character`或字符串`str`。如果它是一個字符串,它將被包裝在一個字符`Character`類中(譯注:可是如果是字符串,也過不了斷言檢查啊!),這樣列表中的所有對象都將是字符`Character`對象。然而,完全有可能的是,那些使用我們代碼的人想使用的類,既不是字符`Character`類也不是字符串類,他們想用鴨子類型。如果對象有字符屬性,我們假設它是一個“類似字符”的對象。如果不是,我們就假設它是一個用`Character`類包裝的“類似字符串”對象。這也有助于程序利用鴨子類型和多態的優勢;只要對象具有字符屬性,就可以用在`Document`類中。
</b>
這個通用檢查其實很有用。例如,如果我們想創建一個語法突出的編輯器:我們可能需要關于字符的額外數據,例如字符屬于哪種類型的語法標記。請注意,如果我們正在做很多類似的比較,將角色實現為帶有適當`__subclasshook__`的抽象基類,可能會更好一些。正如第3章“當對象是相似的”所討論的那樣。
</b>
此外,我們需要修改`Document`類上的字符串`property`來接受新的字符值。我們所需要做的就是在加入每個字符之前調用`str()`。
```
@property
def string(self):
return "".join((str(c) for c in self.characters))
```
這段代碼使用生成器表達式,我們將在第9章“迭代器模式”中討論。這是對序列中的所有對象執行特定操作的快捷方式。
</b>
最后,我們還需要檢查`home`函數和`end`函數中的`Character.character`,我們用它替換我們之前存儲的字符串字符,查看它是否匹配換行符:
```
def home(self):
while self.document.characters[
self.position-1].character != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(
self.document.characters) and \
self.document.characters[
self.position
].character != '\n':
self.position += 1
```
這就完成了字符的格式化。我們可以測試它,看它是否工作:
```
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert(Character('l', bold=True))
>>> d.insert(Character('l', bold=True))
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert(Character('w', italic=True))
>>> d.insert(Character('o', italic=True))
>>> d.insert(Character('r', underline=True))
>>> d.insert('l')
>>> d.insert('d')
>>> print(d.string)
he*l*lo
/w/o_rld
>>> d.cursor.home()
>>> d.delete()
>>> d.insert('W')
>>> print(d.string)
he*l*lo
W/o_rld
>>> d.characters[0].underline = True
>>> print(d.string)
_he*l*lo
W/o_rld
```
不出所料,每當我們打印字符串時,每個粗體字符前面都有一個*字符,每個斜體字符前面都有一個/字符,每個下劃線字符前面都有一個下劃線字符。我們所有的方法似乎一切如常,我們可以修改列表中的字符。我們有一個工作的富文本文檔對象,可以被插入到合適的用戶界面并連接鍵盤進行輸入,并在屏幕輸出。自然而然,我們希望屏幕上顯示真正的粗體、斜體和下劃線的字符,而不是使用我們的`__str__`方法,對于我們要求的基本測試,這已經足夠了。
## 總結
在本章中,我們將重點放在識別對象上,尤其是那些不是顯而易見的對象;以及用于管理和控制的對象。對象應該都有數據和行為,但是`property`可以用來模糊兩者之間的區別。DRY原則是代碼質量的重要指標,并且可以應用組合和繼承來減少代碼重復。
</b>
在下一章中,我們將介紹幾個內置的Python數據結構對象,關注它們面向對象的屬性以及如何擴展或者改編它們。