翻譯:《實用的Python程式設計》04_02_Inheritance

codists 發表於 2021-03-08

目錄 | 上一節 (4.1 類) | 下一節 (4.3 特殊方法)

4.2 繼承

繼承(inheritance)是編寫可擴充套件程式程式的常用手段。本節對繼承的思想(idea)進行探討。

簡介

繼承用於特殊化現有物件:

class Parent:
    ...

class Child(Parent):
    ...

新類 Child 稱為派生類(derived class)或子類(subclass)。類 Parent 稱為基類(base class)或超類(superclass)。在子類名後的括號 () 中指定基類(Parent),class Child(Parent):

擴充套件

使用繼承,你可以獲取現有的類,並且可以:

  • 新增新方法
  • 重新定義現有方法
  • 向例項新增新屬性

最後,你擴充套件了現有程式碼

示例

假設這是開始的類:

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    def cost(self):
        return self.shares * self.price

    def sell(self, nshares):
        self.shares -= nshares

你可以通過繼承更改 Stock 類的任何部分。

新增新方法

class MyStock(Stock):
    def panic(self):
        self.sell(self.shares)

(譯註:“panic” 在這裡表示的是“panic selling”,恐慌性拋售)

使用示例:

>>> s = MyStock('GOOG', 100, 490.1)
>>> s.sell(25)
>>> s.shares
75
>>> s.panic()
>>> s.shares
0
>>>

重新定義現有方法

class MyStock(Stock):
    def cost(self):
        return 1.25 * self.shares * self.price

使用示例:

>>> s = MyStock('GOOG', 100, 490.1)
>>> s.cost()
61262.5
>>>

新的 cost() 方法代替了舊的 cost() 方法。其它的方法不受影響。

方法覆蓋

有時候,一個類既想擴充套件現有方法,同時又想在新的定義中使用原有的實現。為此,可以使用 super() 函式實現(譯註:方法覆蓋 有時也譯為 方法重寫):

class Stock:
    ...
    def cost(self):
        return self.shares * self.price
    ...

class MyStock(Stock):
    def cost(self):
        # Check the call to `super`
        actual_cost = super().cost()
        return 1.25 * actual_cost

使用內建函式 super() 呼叫之前的版本。

注意:在 Python 2 中,語法更加冗餘,像下面這樣:

actual_cost = super(MyStock, self).cost()

__init__ 和繼承

如果 __init__ 方法在子類中被重新定義,那麼有必要初始化父類。

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

class MyStock(Stock):
    def __init__(self, name, shares, price, factor):
        # Check the call to `super` and `__init__`
        super().__init__(name, shares, price)
        self.factor = factor

    def cost(self):
        return self.factor * super().cost()

你需要使用 super 呼叫父類的 __init__() 方法,如前所示,這是呼叫先前版本的方法。

使用繼承

有時候,繼承用於組織相關的物件。

class Shape:
    ...

class Circle(Shape):
    ...

class Rectangle(Shape):
    ...

要組織相關的物件,可以考慮使用邏輯層次結構或者進行分類。然而,一種更常見(更實用)的做法是建立可重用和可擴充套件的程式碼。例如,一個框架可能會定義一個基類,並指導你對其進行自定義。

class CustomHandler(TCPHandler):
    def handle_request(self):
        ...
        # Custom processing

基類包含了通用程式碼。你的類繼承基類並自定義特殊的部分。

“is a” 關係

繼承建立了一種型別關係。

class Shape:
    ...

class Circle(Shape):
    ...

檢查物件例項:

>>> c = Circle(4.0)
>>> isinstance(c, Shape)
True
>>>

重要提示:理想情況下,任何使用父類例項能正常工作的程式碼也能使用子類的例項正常工作。

object 基類

如果一個類沒有父類,那麼有時候你會看到它們使用 object 作為基類。

class Shape(object):
    ...

在 Python 中,object 是所有物件的基類。

注意:在技術上,它不是必需的,但是你通常會看到 object 在 Python 2 中被保留。如果省略,類仍然隱式繼承自 object

多重繼承

你可以通過在類定義中指定多個基類來實現多重繼承。

class Mother:
    ...

class Father:
    ...

class Child(Mother, Father):
    ...

Child 類繼承了兩個父類(Mother,Father)的特性。這裡有一些相當棘手的細節。除非你知道你正在做什麼,否則不要這樣做。雖然更多資訊會在下一節給到,但是我們不會在本課程中進一步使用多重繼承。

練習

繼承的一個主要用途是:以各種方式編寫可擴充套件和可定製的程式碼——尤其是在庫或框架中。要說明這點,請考慮 report.py 程式中的 print_report() 函式。它看起來應該像下面這樣:

def print_report(reportdata):
    '''
    Print a nicely formated table from a list of (name, shares, price, change) tuples.
    '''
    headers = ('Name','Shares','Price','Change')
    print('%10s %10s %10s %10s' % headers)
    print(('-'*10 + ' ')*len(headers))
    for row in reportdata:
        print('%10s %10d %10.2f %10.2f' % row)

當執行 report.py 程式,你應該會獲得像下面這樣的輸出:

>>> import report
>>> report.portfolio_report('Data/portfolio.csv', 'Data/prices.csv')
      Name     Shares      Price     Change
---------- ---------- ---------- ----------
        AA        100       9.22     -22.98
       IBM         50     106.28      15.18
       CAT        150      35.46     -47.98
      MSFT        200      20.89     -30.34
        GE         95      13.48     -26.89
      MSFT         50      20.89     -44.21
       IBM        100     106.28      35.84

練習 4.5:擴充套件性問題

假設你想修改 print_report() 函式,以支援各種不同的輸出格式,例如純文字,HTML, CSV,或者 XML。為此,你可以嘗試編寫一個龐大的函式來實現每一個功能。但是,這樣做可能會導致程式碼非常混亂,無法維護。這是一個使用繼承的絕佳機會。

首先,請關注建立表所涉及的步驟。在表的頂部是標題。標題的後面是資料行。讓我們使用這些步驟把它們放到各自的類中吧。建立一個名為 tableformat.py 的檔案,並定義以下類:

# tableformat.py

class TableFormatter:
    def headings(self, headers):
        '''
        Emit the table headings.
        '''
	raise NotImplementedError()

    def row(self, rowdata):
        '''
        Emit a single row of table data.
        '''
	raise NotImplementedError()

除了稍後用作定義其它類的設計規範,該類什麼也不做。有時候,這樣的類被稱為“抽象基類”。

請修改 print_report() 函式,使其接受一個 TableFormatter 物件作為輸入,並執行 TableFormatter 的方法來生成輸出。示例:

# report.py
...

def print_report(reportdata, formatter):
    '''
    Print a nicely formated table from a list of (name, shares, price, change) tuples.
    '''
    formatter.headings(['Name','Shares','Price','Change'])
    for name, shares, price, change in reportdata:
        rowdata = [ name, str(shares), f'{price:0.2f}', f'{change:0.2f}' ]
        formatter.row(rowdata)

因為你在 portfolio_report() 函式中增加了一個引數,所以你也需要修改 portfolio_report() 函式。請修改 portfolio_report() 函式,以便像下面這樣建立 TableFormatter

# report.py

import tableformat

...
def portfolio_report(portfoliofile, pricefile):
    '''
    Make a stock report given portfolio and price data files.
    '''
    # Read data files
    portfolio = read_portfolio(portfoliofile)
    prices = read_prices(pricefile)

    # Create the report data
    report = make_report_data(portfolio, prices)

    # Print it out
    formatter = tableformat.TableFormatter()
    print_report(report, formatter)

執行新程式碼:

>>> ================================ RESTART ================================
>>> import report
>>> report.portfolio_report('Data/portfolio.csv', 'Data/prices.csv')
... crashes ...

程式應該會馬上崩潰,並附帶一個 NotImplementedError 異常。雖然這沒有那麼令人興奮,但是結果確實是我們期待的。繼續下一步部分。

練習 4.6:使用繼承生成不同的輸出

在 a 部分定義的 TableFormatter 類旨在通過繼承進行擴充套件。實際上,這就是整個思想。要說明這點,請像下面這樣定義 TextTableFormatter 類:

# tableformat.py
...
class TextTableFormatter(TableFormatter):
    '''
    Emit a table in plain-text format
    '''
    def headings(self, headers):
        for h in headers:
            print(f'{h:>10s}', end=' ')
        print()
        print(('-'*10 + ' ')*len(headers))

    def row(self, rowdata):
        for d in rowdata:
            print(f'{d:>10s}', end=' ')
        print()

請像下面這樣修改 portfolio_report() 函式:

# report.py
...
def portfolio_report(portfoliofile, pricefile):
    '''
    Make a stock report given portfolio and price data files.
    '''
    # Read data files
    portfolio = read_portfolio(portfoliofile)
    prices = read_prices(pricefile)

    # Create the report data
    report = make_report_data(portfolio, prices)

    # Print it out
    formatter = tableformat.TextTableFormatter()
    print_report(report, formatter)

這應該會生成和之前一樣的輸出:

>>> ================================ RESTART ================================
>>> import report
>>> report.portfolio_report('Data/portfolio.csv', 'Data/prices.csv')
      Name     Shares      Price     Change
---------- ---------- ---------- ----------
        AA        100       9.22     -22.98
       IBM         50     106.28      15.18
       CAT        150      35.46     -47.98
      MSFT        200      20.89     -30.34
        GE         95      13.48     -26.89
      MSFT         50      20.89     -44.21
       IBM        100     106.28      35.84
>>>

但是,讓我們更改輸出為其它內容。定義一個以 CSV 格式生成輸出的 CSVTableFormatter

# tableformat.py
...
class CSVTableFormatter(TableFormatter):
    '''
    Output portfolio data in CSV format.
    '''
    def headings(self, headers):
        print(','.join(headers))

    def row(self, rowdata):
        print(','.join(rowdata))

請像下面這樣修改主程式:

def portfolio_report(portfoliofile, pricefile):
    '''
    Make a stock report given portfolio and price data files.
    '''
    # Read data files
    portfolio = read_portfolio(portfoliofile)
    prices = read_prices(pricefile)

    # Create the report data
    report = make_report_data(portfolio, prices)

    # Print it out
    formatter = tableformat.CSVTableFormatter()
    print_report(report, formatter)

然後,你應該會看到像下面這樣的 CSV 輸出:

>>> ================================ RESTART ================================
>>> import report
>>> report.portfolio_report('Data/portfolio.csv', 'Data/prices.csv')
Name,Shares,Price,Change
AA,100,9.22,-22.98
IBM,50,106.28,15.18
CAT,150,35.46,-47.98
MSFT,200,20.89,-30.34
GE,95,13.48,-26.89
MSFT,50,20.89,-44.21
IBM,100,106.28,35.84

運用類似的思想,定義一個 HTMLTableFormatter 類,生成具有以下輸出的表格:

<tr><th>Name</th><th>Shares</th><th>Price</th><th>Change</th></tr>
<tr><td>AA</td><td>100</td><td>9.22</td><td>-22.98</td></tr>
<tr><td>IBM</td><td>50</td><td>106.28</td><td>15.18</td></tr>
<tr><td>CAT</td><td>150</td><td>35.46</td><td>-47.98</td></tr>
<tr><td>MSFT</td><td>200</td><td>20.89</td><td>-30.34</td></tr>
<tr><td>GE</td><td>95</td><td>13.48</td><td>-26.89</td></tr>
<tr><td>MSFT</td><td>50</td><td>20.89</td><td>-44.21</td></tr>
<tr><td>IBM</td><td>100</td><td>106.28</td><td>35.84</td></tr>

請通過修改主程式來測試你的程式碼。 主程式建立的是 HTMLTableFormatter 物件,而不是 CSVTableFormatter 物件。

練習 4.7:多型

物件導向程式設計(oop)的一個主要特性是:可以將物件插入程式中,並且不必更改現有程式碼即可執行。例如,如果你編寫了一個預期會使用 TableFormatter 物件的程式,那麼不管你給它什麼型別的 TableFormatter ,它都能正常工作。這樣的行為有時被稱為“多型”。

一個需要指出的潛在問題是:弄清楚如何讓使用者選擇它們想要的格式。像 TextTableFormatter 一樣直接使用類名通常有點煩人。因此,你應該考慮一些簡化的方法。如:你可以在程式碼中嵌入 if 語句:

def portfolio_report(portfoliofile, pricefile, fmt='txt'):
    '''
    Make a stock report given portfolio and price data files.
    '''
    # Read data files
    portfolio = read_portfolio(portfoliofile)
    prices = read_prices(pricefile)

    # Create the report data
    report = make_report_data(portfolio, prices)

    # Print it out
    if fmt == 'txt':
        formatter = tableformat.TextTableFormatter()
    elif fmt == 'csv':
        formatter = tableformat.CSVTableFormatter()
    elif fmt == 'html':
        formatter = tableformat.HTMLTableFormatter()
    else:
        raise RuntimeError(f'Unknown format {fmt}')
    print_report(report, formatter)

雖然在此程式碼中,使用者可以指定一個簡化的名稱(如'txt''csv')來選擇格式,但是,像這樣在 portfolio_report() 函式中使用大量的 if 語句真的是最好的思想嗎?把這些程式碼移入其它通用函式中可能更好。

tableformat.py 檔案中,請新增一個名為 create_formatter(name) 的函式,該函式允許使用者建立給定輸出名(如'txt''csv',或 'html')的格式器(formatter)。請像下面這樣修改 portfolio_report() 函式:

def portfolio_report(portfoliofile, pricefile, fmt='txt'):
    '''
    Make a stock report given portfolio and price data files.
    '''
    # Read data files
    portfolio = read_portfolio(portfoliofile)
    prices = read_prices(pricefile)

    # Create the report data
    report = make_report_data(portfolio, prices)

    # Print it out
    formatter = tableformat.create_formatter(fmt)
    print_report(report, formatter)

嘗試使用不同的格式呼叫該函式,確保它能夠正常工作。

練習 4.8:彙總

請修改 report.py 程式,以便 portfolio_report() 函式使用可選引數指定輸出格式。示例:

>>> report.portfolio_report('Data/portfolio.csv', 'Data/prices.csv', 'txt')
      Name     Shares      Price     Change
---------- ---------- ---------- ----------
        AA        100       9.22     -22.98
       IBM         50     106.28      15.18
       CAT        150      35.46     -47.98
      MSFT        200      20.89     -30.34
        GE         95      13.48     -26.89
      MSFT         50      20.89     -44.21
       IBM        100     106.28      35.84
>>>

請修改主程式,以便可以在命令列上指定輸出格式:

bash $ python3 report.py Data/portfolio.csv Data/prices.csv csv
Name,Shares,Price,Change
AA,100,9.22,-22.98
IBM,50,106.28,15.18
CAT,150,35.46,-47.98
MSFT,200,20.89,-30.34
GE,95,13.48,-26.89
MSFT,50,20.89,-44.21
IBM,100,106.28,35.84
bash $

討論

在庫和框架中,編寫可擴充套件程式是繼承的最常見用途之一。例如,框架指導你定義一個自己的物件,該物件繼承自已提供的基類。然後你可以新增實現各種功能的函式。

另一個更深層次的概念是“擁有抽象的思想”。在練習中,我們定義了自己的類,用於格式化表格。你可能會看一下自己的程式碼,然後告訴自己“我應該只使用格式化庫或其它人已經編寫的東西!”。不,你應該同時使用自己的類和庫。使用自己的類可以降低程式的耦合性,增加程式的靈活性。只要你的程式使用的應用介面來自於自己定義的類,那麼,只要你想,你就可以更改程式的內部實現以使其按照你想的那樣工作。你可以編寫全定製(all-custom)程式碼,也可以使用第三方包(package)。當發現更好的包時,你可以將一個第三方包替換為另一個包。這並不重要——只要你保留這個介面,應用程式程式碼都不會中斷。這是一種強大的思想,這也是為什麼應該使用繼承的原因之一。

也就是說,設計物件導向的程式可能會非常困難。想了解更多資訊,你可能應該尋找一本有關設計模式主題的書看一下(儘管理解本練習中的內容已經讓你以一種實用的方式在使用物件方面走得很遠了)。

目錄 | 上一節 (4.1 類) | 下一節 (4.3 特殊方法)

注:完整翻譯見 https://github.com/codists/practical-python-zh