# 第十七章 類和方法
前兩章我們已經用到了 Python 的一些面向對象的特性了,但那寫程序實際上并不算是真正面向對象的,因為它們并沒能夠表現出用戶自定義類型與對這些類型進行運算的函數之間的關系。所以接下來的移步就是要把這些函數轉換成方法,讓這些關系更明確。
本章的樣例代碼可以在 [這里下載](http://thinkpython2.com/code/Time2.py),然后練習題的樣例代碼可以在[這里下載](http://thinkpython2.com/code/Point2_soln.py)。
## 17.1 面向對象的特性
Python 是一種面向對象的編程語言,這就意味著它提供了一些支持面向對象編程的功能,有以下這些特點:
? 程序包含類和方法的定義。
? 大多數運算都以對象運算的形式來實現。
? 對象往往代表著現實世界中的事物,方法則相對應地代表著現實世界中事物之間的相互作用。
例如,第 16 章中定義的 Time 類就代表了人們生活中計算一天時間的方法,然后當時咱們寫的那些函數就對應著人們對時間的處理。同理,在第 15 章定義的 Point 和 Rectangle 類就對應著現實中的數學概念上的點和矩形。
到此為止,我們還沒有用到 Python 提供的用于面向對象編程的高級功能。這些高級功能并不是嚴格意義上必須使用的;它們大多是提供了一些我們已經實現的功能的一種備選的語法形式。不過在很多情況下,這種備選的模式更加簡潔,也能更加準確地表述程序的結構。
例如,在 Time1.py 里面,類的定義和后面的函數定義就沒有啥明顯的練習。測試一下就會發現,每一個后續的函數里面都至少用了一個 Time 對象作為參數。
這樣的觀察結果就表明可以使用方法;方法是某一特定的類所附帶的函數。之前我們看到過字符串、列表、字典以及元組的一些方法。在本章,咱們將要給用戶自定義類型寫一些方法。
方法在語義上與函數是完全相同的,但在語法上有兩點不同:
? 方法要定義在一個類定義內部,這樣能保證方法和類之間的關系明確。
? 調用一個方法的語法與調用函數的語法不一樣。
在接下來的章節中,我們就要把之前兩章寫過的一些函數改寫成方法。這種轉換是純機械的;你就遵守一系列步驟就可以實現了。如果你對二者之間的相互轉化很熟悉了,你就可以根據情況自己選擇是用函數還是用方法。
## 17.2 輸出對象
在 16.1,我們定義過一個名為 Time 的類,當時寫過月名為 print_time 的函數:
```py
class Time:
"""Represents the time of day."""
def print_time(time):
print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))
```
要調用這個函數,就必須給傳遞過去一個 TIme 對象作為參數:
```py
>>> start = Time()
>>> start.hour = 9
>>> start.minute = 45
>>> start.second = 00
>>> print_time(start)
09:45:00
```
要讓 print_time 成為一個方法,只需要把函數定義內容放到類定義里面去。一定要注意縮進的變化哈。
```py
class Time:
def print_time(time):
print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))
```
現在就有兩種方法來調用 print_time 這個函數了。第一種就是用函數的語法(一般大家不這么用):
```py
>>> Time.print_time(start)
09:45:00
```
上面這里用到了點號,Time 是類的名字,pritn_time 是方法的名字。start 就是傳過去的一個參數了。
另外一種形式就是用方法的語法(這個形式更簡潔很多):
```py
>>> start.print_time()
09:45:00
```
在上面這里也用了點號,print_time 依然還是方法名字,然后 start 是調用方法所在的對象,也叫做主語。這里就如同句子中的主語一樣,方法調用的主語就是方法的歸屬者。
在方法內部,主語被用作第一個參數,所以在上面的例子中中,start 就被賦值給了 time。
按照慣例,方法的第一個參數也叫做 self,所以剛剛的 print_time 函數可以以如下這種更通用的形式來寫:
```py
class Time:
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
```
The reason for this convention is an implicit metaphor:
>這種改寫還有更深層次的意義:
? 函數調用的語法里面,print_time(start),就表明了函數是主動的。這句語句的意思就相當于說,『嘿,print_time 函數!給你這個對象,你來打印輸出一下。』
? 在面向對象的編程中,對象是主動的。方法的調用,比如 start.rint_time(),就像是說,『嘿,start,你打印輸出一下你自己』
看上去這樣改了之后客氣了不少,實際上不止如此,還有更多用處,只是不太明顯。目前我們看到過的例子里面,這樣改寫還沒有造成什么區別。但是有的時候,從函數轉為對象,能夠讓函數(或者方法)更加通用,也讓程序更容易維護,還便于代碼的重用。
做個練習吧,重寫一下 time_to_int(參見 16.4),把這個函數寫成一個方法。你也可以試著把 int_to_time 也攜程方法,不過這可能不太行得通,因為把這個函數改成方法的話,沒有對象來調用方法。
## 17.3 另外一個例子
下面是 increment 函數(參見 16.4)被改寫成的方法:
```py
# inside class Time:
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
```
這一版本的前提是 time_to_int 已經被改寫成方法了。另外也要注意到,這是一個純函數,而不是修改器。
>下面是調用 increment 的示范:
```py
>>> start.print_time()
09:45:00
>>> end = start.increment(1337)
>>> end.print_time()
10:07:17
```
主語,start,用自己(self)賦值給第一個參數。然后參數,1337,賦值給了第二個參數,秒值 seconds。
這種表述挺混亂,如果弄錯了就更麻煩了。比如,如果你用兩個參數調用了 increment 函數,你會得到如下的錯誤:
```py
>>> end = start.increment(1337, 460)
TypeError: increment() takes 2 positional arguments but 3 were given
```
這個錯誤信息剛開始看上去還挺不好理解,因為括號里面確實是只有兩個參數。不過實際上主語也會把自己當做一個參數,所以總共實際上是有三個參數了。
另外,有一種參數叫位置參數,就是沒有參數的名字;這種參數就和關鍵字參數不同了。下面這個函數調用中:
```Bash
sketch(parrot, cage, dead=True)
```
parrot 和 cage 都是位置參數,dead 是關鍵字參數。
## 17.4 更復雜點的例子
重寫 is_after(參見 16.1),這就比較有難度了,因為這個函數接收兩個 Time 對象作為參數。在這個情況下,一般就把第一個參數命名為 self,第二個命名為 other:
```py
# inside class Time:
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
```
要使用這個方法,就必須在一個對象后面調用,然后用另外一個對象作為參數:
```py
>>> end.is_after(start)
True
```
這里就體現出一種語法上的好處了,因為讀起來基本就根英語是一樣的嗯:『end is after start?』
## 17.5 init 方法
init 方法(就是對『initialization』的縮寫,初始化的意思,這個方法相當于 C++中的構造函數)是一種特殊的方法,在對象被實例化的時候被調用。這個方法的全名是 __init__(兩個下劃線,然后是 init,然后還是兩個下劃線)。在 Time 類當中,init 方法示例如下:
```py
# inside class Time:
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
```
一般情況下,init 方法里面的參數與屬性變量的名字是相同的。下面這個語句
```py
self.hour = hour
```
就存儲了參數 hour 的值,賦給了屬性變量 hour 本身。
這些參數都是可選的,所以如果你調用 Time 但不給任何參數,得到的就是默認值。
```py
>>> time = Time()
>>> time.print_time()
00:00:00
```
如果你提供一個參數,就先覆蓋 hour 的值:
```py
>>> time = Time (9)
>>> time.print_time()
09:00:00
提供兩個參數,就先后覆蓋了 hour 和 minute 的值。
```py
>>> time = Time(9, 45)
>>> time.print_time()
09:45:00
```
如果你給出三個參數,就依次覆蓋掉所有三個默認值了。
做一個練習,寫一個 Point 類的 init 方法,接收 x 和 y 作為可選參數,然后賦值給對應的屬性。
## 17.6 __str__ 方法
__str__ 是一種特殊的方法,就跟 __init__ 差不多,str 方法是接收一個對象,返回一個代表該對象的字符串。
例如,下面就是 Time 對象的一個 str 方法:
```py
# inside class Time:
def __str__(self):
return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
```
這樣當你用 print 打印輸出一個對象的時候,Python 就會調用這個 str 方法:
```py
>>> time = Time(9, 45)
>>> print(time) 09:45:00
```
寫一個新的類的時候,總要先寫出來 init 方法,這樣有利于簡化對象的初始化,還要寫個 str 方法,這個方法在調試的時候很有用。
做個練習,寫一下 Point 這個類的 str 方法。創建一個 Point 對象,然后用 print 輸出一下。
## 17.7 運算符重載
通過定義一些特定的方法,咱們就能針對自定義類型,讓運算符有特定的作用。比如,如果你在 Time 類中定義了一個名字為 __add__ 的方法,你就可以對 Time 對象使用『+』加號運算符。
```py
# inside class Time:
def __add__(self, other):
seconds = self.time_to_int() + other.time_to_int()
return int_to_time(seconds)
```
使用方法如下所示:
```py
>>> start = Time(9, 45)
>>> duration = Time(1, 35)
>>> print(start + duration)
11:20:00
```
當你針對 Time 對象使用加號運算符的時候,Python 就會調用你剛剛自定義的 add 方法。當你用 print 輸出結果的時候,Python 調用的是你自定義的 str 方法。所以實際上面這個簡單的例子背后可不簡單。
針對用戶自定義類型,讓運算符有相應的行為,這就叫做運算符重載。Python 當中每一個運算符都有一個對應的方法,比如 __add__。更多內容可以看一下 [這里的文檔](http://docs.python.org/3/reference/datamodel.html#specialnames)。
做個練習,給 Point 類寫一個加法的方法。
## 17.8 根據對象類型進行運算
在前面的章節中,我們把兩個 Time 對象進行了相加,但也許有時候需要把一個整數加到 Time 對象上面。下面這一個版本的 __add__ 方法就能夠實現檢查類型,然后調用 add_time 方法或者是 increment 方法:
```py
# inside class Time:
def __add__(self, other):
if isinstance(other, Time):
return self.add_time(other)
else:
return self.increment(other)
def add_time(self, other):
seconds = self.time_to_int() + other.time_to_int()
return int_to_time(seconds)
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
```
內置函數 isinstance 接收一個值和一個類的對象,如果該值是這個類的一個實例,就會返回真。
如果拿來相加的是一個 Time 對象,__add__ 就會調用 add_time 方法。其他情況下,程序會把參數當做一個數字,然后就調用 increment 方法。這種運算就是根據對象進行的,因為在針對不同類型參數的時候,運算符會進行不同的計算。
下面的例子中,就展示了用不同類型變量來相加的效果:
```py
>>> start = Time(9, 45)
>>> duration = Time(1, 35)
>>> print(start + duration)
11:20:00
>>> print(start + 1337)
10:07:17
```
然而不幸的是,這個加法運算不滿足交換率。如果整數放到首位,就會得到如下所示的錯誤了:
```py
>>> print(1337 + start)
TypeError: unsupported operand type(s) for +: 'int' and 'instance'
```
這里的問題就在于,Python 并沒有讓一個 Time 對象來加一個整數,而是去調用了整形的加法去把一個 Time 對象加到整數上面去,這就用系統原本的加法,而這個加法不能處理 Time 對象。有一個很聰明的方法來解決這個問題:用一個特殊的方法 __radd__,這個方法的意思就是『右加』。在一個 Time 對象出現在加號運算符右側的時候,該方法就會被調用了。下面就是這個方法的定義:
```py
# inside class Time:
def __radd__(self, other):
return self.__add__(other)
```
And here’s how it’s used:
>使用如下所示:
```py
>>> print(1337 + start)
10:07:17
```
做個練習,為 Point 類來寫一個加法的方法,要求能處理 Point 對象或者一個元組:
? 如果第二個運算數是一個 Point,該方法就應該返回一個新的 Point,新點的橫縱坐標分別為兩個點坐標相加。
? 如果第二個運算數是一個元組,該方法就要把元組中第一個元素加到橫坐標上,把第二個元素加到縱坐標上面,然后用計算出來的坐標返回一個新的點。
## 17.9 多態
在必要的時候,根據類型運算還是很有用的,不過(還好)并不總需要這么做。一般你都可以把函數寫成能處理不同類型參數的,這樣就不用這么麻煩了。
我們之前為字符串寫的很多函數,也都可以用到其他序列類型上面。比如在 11.2 我們用 histogram 來統計一個單詞中每個字母出現的次數。
```py
def histogram(s):
d = dict()
for c in s:
if c not in d:
d[c] = 1
else:
d[c] = d[c]+1
return d
```
這個函數也可以用于列表、元組,甚至字典,只要 s 的元素是散列的,就能用做 d 當中的鍵。
```py
>>> t = ['spam', 'egg', 'spam', 'spam', 'bacon', 'spam']
>>> histogram(t)
{'bacon': 1, 'egg': 1, 'spam': 4}
```
針對不同類型都可以運行的函數,就是多態的了。多態能夠有利于代碼復用。比如內置的函數 sum,是用來把一個序列中所有的元素加起來,就可以適用于所有能夠相加的序列元素。
Time 對象有了自己的加法方法,就可以與 sum 函數來配合使用了:
```py
>>> t1 = Time(7, 43)
>>> t2 = Time(7, 41)
>>> t3 = Time(7, 37)
>>> total = sum([t1, t2, t3])
>>> print(total)
23:01:00
```
總的來說,如果一個函數內的所有運算都可以處理某一類型,這個函數就適用于這一類型了。
最好就是無心插柳柳成蔭的這種多態,這種情況下你會發現某個之前寫過的函數可以用來處理一個之前沒有計劃到的類型。
## 17.10 調試
在程序運行的任意時刻都可以給對象增加屬性,不過如果你有多個同類對象卻又不具有相同的屬性,就容易出錯了。所以最好在對象實例化的時候就全部用 init 方法初始化對象的全部屬性。
如果你不確定一個對象是否有某個特定的屬性,你可以用內置的 hasattr 函數來嘗試一下(參考 15.7)。
另外一種讀取屬性的方法是用內置函數 vars,這個函數會接收一個對象,然后返回一個字典,字典中的鍵值對就是屬性名的字符串與對應的值。
```py
>>> p = Point(3, 4)
>>> vars(p)
{'y': 4, 'x': 3}
```
出于調試目的,你估計也會發現下面這個函數隨時用一下會帶來很多便利:
```py
def print_attributes(obj):
for attr in vars(obj):
print(attr, getattr(obj, attr))
```
內置函數 getattr 會接收一個對象和一個屬性名字(以字符串形式),然后返回該屬性的值。
## 17.11 接口和實現
面向對象編程設計的目的之一就是讓軟件更容易維護,這就意味著當系統中其他部分發生改變的時候依然能讓程序運行,然后可以修改程序去符合新的需求。
實現這一目標的程序設計原則就是要讓接口和實現分開。對于對象來說,這就意味著一個類包含的方法要不能被屬性表達方式的變化所影響。
比如,在本章我們建立了一個表示一天中時間的類。該類提供的方法包括 time_to_int, is_after, 和 add_time。
我們可以用幾種不同方式來實現這些方法。這些實現的細節依賴于我們如何去表示時間。在本章,一個 Time 對象的屬性為時分秒三個變量。
還有一種替代方案,我們就可以把這些屬性替換為一個單個的整形變量,表示從午夜零點到當前時間的秒的數目。這種實現方法可以讓一些方法更簡單,比如 is_after,但也讓其他方法更難寫了。
當你創建一個新的類之后,你可能會發現有更好的實現方式。如果一個程序的其他部位在用你的類,這時候再來改造接口可能就要消耗很多時間,也容易遇到很多錯誤了。
但如果你仔細地設計好接口,你在改變實現的時候就不用去折騰了,這就意味著你程序的其他部位都不需要改動了。
## 17.12 Glossary 術語列表
object-oriented language:
A language that provides features, such as programmer-defined types and methods, that facilitate object-oriented programming.
>面向對象的編程語言:提供面向對象功能的語言,比如用戶自定義類型和方法,有利于實現面向對象編程。
object-oriented programming:
A style of programming in which data and the operations that manipulate it are organized into classes and methods.
>面向對象編程:一種編程模式,數據和運算都被封裝進類和方法之中。
method:
A function that is defined inside a class definition and is invoked on instances of that class.
>方法:類內所包含的函數就叫方法,可以在類的接口中被調用。
subject:
The object a method is invoked on.
>主語:調用方法的對象。
positional argument:
An argument that does not include a parameter name, so it is not a keyword argument.
>位置參數:一種參數,沒有參數名字,不是關鍵字參數。
operator overloading:
Changing the behavior of an operator like + so it works with a programmer-defined type.
>運算符重載:像+加號這樣的運算符,在處理用戶自定義類型的時候改變為相應的運算。
type-based dispatch:
A programming pattern that checks the type of an operand and invokes different functions for different types.
>按類型處理:一種編程模式,檢查運算數的類型,然后根據類型調用不同的函數來進行運算。
polymorphic:
Pertaining to a function that can work with more than one type.
>多態:一個函數能處理多種類型的特征,就叫做多態。
information hiding:
The principle that the interface provided by an object should not depend on its implementation, in particular the representation of its attributes.
>信息隱藏:一種開發原則,一個對象提供的接口應該獨立于其實現,尤其是不受對象屬性設置變化的影響。
## 17.13 練習
### 練習 1
從[這里](http://thinkpython2.com/code/Time2.py)下載本章的代碼。把 Time 中的屬性改變成一個單獨的整型變量,用來表示自從午夜至今的秒數。然后修改一下各個方法(以及 int_to_time 函數),讓所有功能都能在新的實現下正常工作。盡量就讓自己不用去更改 main 當中的測試代碼。你改完之后,輸出應該與之前相同。[樣例代碼](http://thinkpython2.com/code/Time2_soln.py)
### 練習 2
這個練習是一個廣為流傳的寓言故事,其中包含了一個使用 Python 的時候最常見但也是最難發現的錯誤。寫一個名為袋鼠的類的定義,要求有如下的方法:
1. 一個 __init__ 方法,用來初始化一個名為 puntch_contents(就是袋鼠的袋子中內容的意思) 的屬性,把該屬性初始化為一個空列表。
2. 一個名為 put_in_pouch 的方法,接收任意類型的一個對象,把這個對象放進 pouch_contents 中。
3. 一個 __str__ 方法,返回袋鼠對象的字符串表示,以及袋鼠袋子中的內容。
通過建立兩個袋鼠對象來測試一下你的代碼,把它們倆分別命名為 kanga 和 roo,然后把 roo 添加到 kanga 的袋子中。
下載[這個代碼](http://thinkpython2.com/code/BadKangaroo.py)。里面包含了上面這個練習的一個樣例代碼,但這個代碼有很大很悲催的 bug。找出這個 bug 然后改過來吧。
如果你搞不定了,可以下載[這個代碼](http://thinkpython2.com/code/GoodKangaroo.py),這個代碼中解釋了整個問題,并且提供了一個可行的解決方案。