Python抽象基類:ABC謝謝你,因為有你,溫暖了四季!

i發表於2022-04-23

Python抽象基類:ABC謝謝你,因為有你,溫暖了四季!

最近閱讀了《Python Tricks: The Book》的第四章“Classes & OOP”,這一章節介紹了Python對物件導向程式設計的支援,內容包括“is”和“==”的區別、特殊方法“__str__”和“__repr__”的作用、自定義異常類、淺拷貝深拷貝、抽象基類以及例項方法和類方法的區別等。本文記錄自己學習這些知識點的心得體會,重點討論其中的例項方法、類方法和靜態方法的應用場景、抽象類以及具名元組等內容。

例項方法、類方法和靜態方法

例項方法、類方法和靜態方法是三個不同的概念:

  • 例項方法用於訪問和修改類例項的屬性,它的第一個引數是“self”。當通過類名呼叫例項方法時,需要提供類的例項作為引數(傳一個物件作為引數);當通過類的例項呼叫例項方法時,Python會自動把例項方法繫結到呼叫方,例項方法的“self”引數就是呼叫它的類例項(可以類比functools.partial?):

    class Pizza:
        def __init__(self, size: int):
            self.size = size
    
        def get_size(self) -> int:
            return self.size
    
    Pizza.get_size()  # TypeError, missing argument: 'self'
    Pizza.get_size(Pizza(25))  # 25
    Pizza(21).get_size()  # 21,不用顯式指定例項方法的“self”引數啦
    
  • 類方法常被用作工廠方法,用於建立類的例項(可以類比Java中類的構造方法),它的第一個引數是“cls”。類的例項可能有多種構造方案,比如時間類,既可以通過指定年月日時分秒構造例項,也可以通過解析字串“%Y-%m-%d %H:%M:%S”進行構造,而Python規定了一個類只能定義一個初始化方法“__init__”,這限制了根據類建立例項的靈活性。類方法是緩解該矛盾的有效方案:

    from typing import List
    
    class Pizza:
        def __init__(self, ingredients: List[str]):
            self.ingredients = ingredients
    
        def __repr__(self):  # 定義把物件轉化為字串的方法,!r表示呼叫變數的__repr__方法
            return f'Pizza({self.ingredients!r})'
    
        @classmethod  # 類方法使用@classmethod裝飾器修飾
        def margherita(cls):
            """"瑪格麗塔,一種披薩的名稱"""
            return cls(['mozzarella', 'tomatoes'])
    
        @classmethod
        def prosciutto(cls):
            """火腿披薩"""
            return cls(['mozzarella', 'tomatoes', 'ham'])
    
    Pizza(['mozzarella', 'tomatoes'])
    Pizza.margherita()  # Pizza(['mozzarella', 'tomatoes'])
    Pizza.prosciutto()  # Pizza(['mozzarella', 'tomatoes', 'ham'])
    
  • 靜態方法跟定義在相同模組中的函式沒有明顯的區別,它不依賴於類變數或例項物件的狀態,需要使用“@staticmethod”裝飾器修飾:

    from typing import List
    import math
    
    class Pizza:
        def __init__(self, ingredients: List[str]):
            self.ingredients = ingredients
    
        @staticmethod
        def circle_area(radius: float) -> float:
            return radius ** 2 * math.pi
    
    pizza = Pizza(['mozzarella', 'tomatoes'])
    pizza.circle_area(4)  # 50.27
    Pizza.circle_area(4)  # 50.27
    

抽象類

抽象類是指宣告瞭抽象方法的類,它不能被例項化,但可以被繼承。當抽象類被繼承時,子類往往需要實現父類的所有抽象方法(如果只實現部分抽象方法,那子類也是抽象類)。抽象類常用於型別檢查,即判斷給定的類是否由某個基類派生(issubclass())或給定的物件是否為某個基類的例項(isinstance())。

import collections

issubclass(list, collections.abc.Iterable)  # True
issubclass(list, collections.abc.Hashable)  # False

a = [1, 0, 2, 4]
isinstance(a, collections.abc.Sequence)  # True
isinstance(a, collections.abc.Mapping)  # False

此外,抽象類的使用能夠讓類的層次關係變得清晰,方便程式碼的開發和維護;抽象類宣告介面,子類給出具體實現,子類例項的行為變得可以預期。舉例來說,抽象類“collections.abc.Sized”宣告瞭抽象方法“__len__”,Python的內建型別list、tuple、set、dict等都繼承自該抽象類,因此可以通過“len()”函式獲取這些型別的例項的大小。

早期Python通過在方法的定義體中丟擲“NotImplementedError”異常的方式來宣告抽象方法,抽象基類(Abstract Base Classes,ABC)出現以後,有了更好的方案。

  1. 通過丟擲“NotImplementedError”異常定義抽象類

    class Base:
    """基於NotImplementedError異常的抽象類"""
    def foo(self):
        raise NotImplementedError()
    
    b = Base()  # 雖然名為抽象類,但還是可以被例項化
    
  2. 通過繼承“abc.ABC”定義抽象類

    import abc  # 匯入抽象基類模組
    
    class Base(abc.ABC):
        """"通過繼承abc.ABC定義抽象類"""
        
        @abc.abstractmethod
        def foo(self):
            """"抽象方法使用@abc.abstractmethod裝飾器標記"""
        
        def bar(self):
            pass  # 抽象類可以包含具體方法
    
    class Concrete(Base):
        def foo(self):
            print('聽我說謝謝你,因為有你,溫暖了四季~')
    
    b = Base()  # TypeError: Can't instantiate abstract class Base with abstract methods foo
    c = Concrete()
    c.foo()  # 聽我說謝謝你,因為有你,溫暖了四季~
    

具名元組

元組是不可變的列表,常被用於表示資料的記錄(類比Java的Record型別?關係型資料庫也把表中的一行資料稱為元組)。元組中的元素只能通過索引進行訪問,而整型的索引難以表示元素在資料記錄中的語義,最終導致程式碼的可讀性變差。為了解決該問題,Python提出了具名元組(Named Tuple),允許通過可讀性強的識別符號訪問元組中的元素。有兩種定義具名元組的方式:(1)利用“collections.namedtuple”工廠函式定義具名元組;(2)通過繼承“typing.NamedTuple”定義具名元組。

  1. 利用“collections.namedtuple”工廠函式定義具名元組

    namedtuple工廠函式接收一個識別符號和欄位列表作為引數,返回以該識別符號命名的類(是內建型別tuple的子類)。

    from collections import namedtuple
    
    Car = namedtuple('Car' , ['color', 'mileage'])
    tesla = Car('black', mileage=376.5)  # 建立例項時,提供位置引數、關鍵字引數均可
    tesla.color  # 'black',現在可以通過識別符號訪問元組的元素啦
    tesla._asdict()  # {'color': 'black', 'mileage': 376.5}
    
    tesla._replace(color='white')  # Car(color='white', mileage=376.5),通過替換元素構造新的元組
    Car._make(['white', 376.5])  # Car(color='white', mileage=376.5),通過類方法構造新的元組
    Car._fields  # ('color', 'mileage')
    
  2. 通過繼承“typing.NamedTuple”定義具名元組

    import typing
    
    class Car(typing.NamedTuple):  # 繼承typing.NamedTuple
        color: str
        mileage: float
    
    tesla = Car('black', mileage=376.5)
    tesla.color  # 'black'
    tesla._asdict()  # {'color': 'black', 'mileage': 376.5}
    
    Car._fields  # ('color', 'mileage')
    Car._field_types  # {'color': str, 'mileage': float},相比於利用工廠函式定義的方式多了型別資訊
    

具名元組佔用的空間與內建的元組型別是相同的,這是我不能理解的地方。儘管欄位名列表、型別資訊可以繫結到類上面,但是諸如“_asdict()”、“_replace()”這樣的例項方法需要繫結到具名元組的例項上,這為什麼沒有帶來空間開銷呢?還是說通過“sys.getsizeof()”獲得的物件佔用記憶體空間的大小跟想的是不太一樣?

import sys

a = ('black', 376.5)
sys.getsizeof(a)  # 56,普通元組佔用56個位元組的空間

b = Car('black', 376.5)
sys.getsizeof(b)  # 56,具名元組同樣佔用56個位元組的空間

除了具名元組,定義資料類的另一種方式是使用“dataclasses”模組,由於篇幅限制,這裡就不再展開介紹了。

參考資料

  1. Python Tricks: The Book
  2. 《流暢的Python》,第十一章“介面:從協議到抽象基類”
  3. The definitive guide on how to use static, class or abstract methods in Python
  4. 正確理解Python中的@staticmethod@classmethod方法
  5. Python中的abc模組

相關文章