iT邦幫忙

2022 iThome 鐵人賽

DAY 13
0
Software Development

Oops! OOPP: An Introduction to Object-Oriented Programming in Python系列 第 13

自動產生Boilerplate Code的好幫手:Dataclass

  • 分享至 

  • xImage
  •  

今天介紹一個Python在設計類別時的小工具dataclass模組,大概勉強和封裝攀得上一點關係。一表三千里嘛。


  • 之前我們看到的類別設計範例,其實只能算是簡易入門版。真要設計一個Python的完整類別,必須考慮更多才夠周延。這代表要寫更多的code。除了getters, setters, deleters及其他和商業邏輯有關的方法之外,最好還要加上一些稱為boilerplate code(註1)的方法。

  • 為了講解方便,以下這段code姑且名為「傳統版」。請注意那幾個__xx__()魔術方法,那些就是剛剛說的boilerplate code。這個術語就直接用英文,不中譯了。

    class Tree():
        '''為了聚焦本class僅實作getters,略去setters, deleters。'''
        def __init__(self, breed: str, age: int, height: int):
            self.__breed = breed
            self.__age = age
            self.__height = height
    
        @property
        def breed(self) -> str:
            '''The breed property(getter).'''
            return self.__breed
    
        @property
        def age(self) -> int:
            '''The age property(getter).'''
            return self.__age
    
        @property
        def height(self) -> int:
            '''The height property(getter).'''
            return self.__height
    
        # 真要設計完整的class,還得實作以下的boilerplate code。
        def __repr__(self) -> str:
            return f'Tree({self.breed=}, {self.age=}, {self.height=})'
    
        def __hash__(self):
            return hash((self.__class__, self.breed, self.age, self.height))
    
        def __eq__(self, other):
            if other.__class__ is self.__class__:
                return self.breed == other.breed and self.age == other.age and self.height == other.height
            else:
                return NotImplemented
    
  • 再嘮叨一下:上面的魔術方法__repr__(), __hash__(), __eq__(),還有沒寫到的__new__(), __str__(), __lt__(), __gt__()...等等(族繁不及備載),就是boilerplate code

  • boilerplate code形式和內容比較公式化,但有時沒它又不行,一些功能會出不來。還有,這些Python類別的boilerplates有些是必需品,有些大概可有可無(optional),視需求而定。

  • 檢查一下:

    tree = Tree('beech', 260, 307)
    print(f'\n{tree}\n')
    

    「傳統版」輸出:
    https://ithelp.ithome.com.tw/upload/images/20220928/2014848597egtMu96X.png
    表示__repr__()code正確無誤。

  • 不過,每次設計類別,都得複製貼上這些boilerplate程式碼再略加修改,也屬煩事一椿。如能偷懶不寫該有多好...

  • 懶惰為發明之母。dataclass大概就在這個需求下,於Python 3.7版時降生了。

  • 上面「傳統版」的code,改套dataclass後長這樣子。無以名之,且稱「簡潔版」:

    from dataclasses import dataclass
    
    @dataclass       # 這裡要加一個decorator。
    class Tree():
        '''使用dataclass時不用寫那幾個boilerplates。dataclass會自動幫我們產生。'''
        __breed: str    # 表面上已沒有constructor,只有私有屬性的名稱。
        __age: int      # 語法非常簡潔。
        __height: int
    
        @property
        def breed(self) -> str:
            '''The breed property(getter).'''
            return self.__breed
    
        @property
        def age(self) -> int:
            '''The age property(getter).'''
            return self.__age
    
        @property
        def height(self) -> int:
            '''The height property(getter).'''
            return self.__height
    
        # 這時不用寫那幾個boilerplates了。dataclass會自動幫我們產生。
    
    
    tree = Tree('beech', 260, 307)
    print(f'\n{tree}\n')    
    

    「簡潔版」輸出:
    https://ithelp.ithome.com.tw/upload/images/20220928/20148485VNTakNHS04.png

  • 套用datacalss模組的「簡潔版」程式碼是不是清爽多了?注意:以上的程式碼並沒有實作__repr__()以及其他的__xx__()那些boilerplates是由dataclass幫忙自動產生的

  • 再貼一次「傳統版」的輸出以資比較:
    https://ithelp.ithome.com.tw/upload/images/20220928/2014848597egtMu96X.png

  • 兩者內容基本上一樣。不過「傳統版」因為是自行撰寫,要寫甚麼都可以。而由dataclass產生的「簡潔版」,彈性也許沒那麼大,內容和形式都套模板,所以是固定的。

__post_init__()方法

  • dataclass模組還額外提供__post_init()方法。是在執行完建構子__init__()之後自動執行的方法。本例是利用__post_init()來查出每棵樹在植物分類學上屬於哪一個family,即「界|門|綱|目|科|屬|種」中的「科」:

    from dataclasses import dataclass
    
    @dataclass
    class Tree():
        '''依然不用寫那些boilerplates。'''
        __breed: str
        __age: int
        __height: int
    
        def __post_init__(self):  # 這是dataclass模組提供的方法。
            '''查出每個breed(一般用語的樹種)在植物分類學上屬於哪一個family(科)。'''
            families = {'cedar': 'Pinaceae', 
                        'oak': 'Fagaceae', 
                        'beech': 'Fagaceae', 
                        'camphor': 'Lauraceae', 
                        'maple': 'Sapindaceae', 
                        'phoebe': 'Lauraceae'
                        }
            self.__family = families.get(self.__breed)
    
        @property
        def family(self):   # 當然還得提供__family的getter。
            '''The family property(getter).'''
            return self.__family
    
        @property
        def breed(self):    
            '''The breed property(getter).'''
            return self.__breed
    
        @breed.setter     # 本版順手補上setter。
        def breed(self, breed: str):
            '''The breed property(setter).'''
            if not isinstance(breed, str):
                raise TypeError('樹種必須是字串。')
    
            breeds = {'cedar': (0, 5_000), 'oak': (0, 300), 
                      'beech': (0, 400), 'camphor': (0, 800), 
                      'maple': (0, 500), 'phoebe': (0, 2_000)}
            breed = breed.strip().lower()
            if breed not in breeds:
                raise Exception(f"樹種名稱'{breed}'不正確。")
            min_age = breeds.get(breed)[0]
            max_age = breeds.get(breed)[1]
            if self.__age < min_age or self.__age > max_age:
                raise Exception(f"新樹種名稱'{breed}'和其年齡不匹配。")
            self.__breed = breed
        @property
        def age(self) -> int:
            '''The age property(getter).'''
            return self.__age
    
        @age.setter     # 本版順手補上setter。
        def age(self, age: int):
            '''The age property(setter).'''
            if isinstance(age, bool) or not isinstance(age, int):
                raise TypeError('樹齡必須是整數。')
            # 以下的條件判斷只是「示意」,實際上該和breed一併考慮才對。
            if age > 15_000 or age < 0:
                raise Exception(f'樹齡數字{age}不合理。')
            self.__age = age
    
        @property
        def height(self):
            '''The height property(getter).'''
            return self.__height
    
        @height.setter     # 本版順手補上setter。
        def height(self, height: int):
            '''The height property(setter).'''
            if isinstance(height, bool) or not isinstance(height, int):
                raise TypeError('樹高必須是整數。')
            if height > 200 or height < 1:
                raise Exception(f'樹高數字{height}不合理。')
            self.__height = height
    
        # 不用寫那些boilerplates。
    

    測試程式:

    def show_breed_family(breed: str, family: str):
        print(f'{breed=:12}{family=}')
    
    tree1 = Tree('beech', 260, 307)
    show_breed_family(tree1.breed, tree1.family)
    
    tree2 = Tree('phoebe', 1_800, 56)
    show_breed_family(tree2.breed, tree2.family)
    
    tree3 = Tree('cedar', 3_560, 44)
    show_breed_family(tree3.breed, tree3.family)    
    

    輸出:
    https://ithelp.ithome.com.tw/upload/images/20220928/2014848569A0AvXLCl.png

  • __post_init()大致如此使用。


  • 以上只介紹了dataclass模組的部分功能。dataclass還有好些比較細部的設定,例如可加參數(init, repr, eq, order, frozen, unsafe_hash),field()方法(可用來設定屬性的預設值)等,或明天詳細交代,或乾脆就此點到為止。等頭腦清醒一點再決定。

註1: boilerplate code有人稱為「模板程式碼」。意思是一些有需要且不能省,但形式較為固定,內容不會改變或只需小幅度修改的code。

  • 例如C++的:
    #include <iostream>
    using namespace std;
    int main()
    {
            return 0;
    }
    
    就是boilerplate code。
  • 有人認為Python的:
    if __name__ == '__main__':
        ...
    
    也算是個boilerplate。

上一篇
Property的應用Scenario
下一篇
More about Dataclass Decorator
系列文
Oops! OOPP: An Introduction to Object-Oriented Programming in Python30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言