iT邦幫忙

2022 iThome 鐵人賽

DAY 12
0

今天繼續property之旅。可能是最後一篇了,試用講故事形式。


  • 話說有一間Alex & Son Co.公司,內有團隊A(PM為Oliver,成員有Spring, Ming, Jean, Melody, Andy, Johnny, Sophie等),想開發一個名(這是「子曰」的曰,我沒打錯)alextree的套件,其中有一個Tree類別。
  • 該套件先提供給公司另一團隊B(PM為Eric,成員有Mic, Ryan, Doris, Sherry, James, Victor, Ken等)試用。如果團隊B用後反應良好,就發布到PyPI,讓全球Pythonistaspip install alextree,以提升Alex & Son Co.公司聲譽。
  • 1.0版的Tree類別很快就開發出來。不過由於某種神秘原因,所有屬性都設定成公開。
    class Tree():
        '''version 1.0'''
    
        def __init__(self, breed: str, age: int, height: int):
            self.breed = breed        # 所有attributes全是public。
            self.age = age            # 存取最簡單,但也最缺保護。
            self.height = height
    
        def xx(self):   # 略
            ...    # Ellipsis
    
        def yy(self):   # 略
            ...    # Ellipsis
    
        def show_info(self):
            print(f'{self.breed=:<10}{self.age=:<10,}{self.height=}')
    
  • 類別設計完,團隊A的Oliver高高興興交給團隊B的Eric使用。
  • 不料團隊B有些使用者,或「白目」或故意惡搞,讓物件的屬性亂成一團。除非fire掉這些惡搞者,否則還阻止不了。問題是:fire掉白目1,新聘進來的可能是進化版的白目2。從此進入沒有break的while True無窮迴圈。
  • 胡搞的code如下:
    tree = Tree('cedar', 50, 37)
    tree.show_info()
    tree.breed = 123     # 將整數賦給樹種
    tree.age = True      # 將bool賦給樹齡
    tree.height = -50    # 樹高為負
    tree.show_info()
    
    輸出:
    https://ithelp.ithome.com.tw/upload/images/20220927/20148485NfuQsodlpU.png
  • 錯誤的資料橫行無阻,類別內部無任何查驗機制
  • 這種紕繆,有時連事後檢核都不一定抓得出來。像本例在賦值時明明是tree.age = True,存進去的卻是1(註1),而1的型和值都符合規定(1歲的樹是允許的呀),因而大幅增加檢核的複雜程度。
  • 兩隊的PM Oliver和Eric,加上Spring, Mic等資深成員緊急開會,討論如何補救此一漏洞。會中Oliver拍胸口表示:安啦,以本團隊水準,此bug乃a piece of cake耳。保證幾天搞(非「定」),包君滿意。
  • 既然Oliver作出如此保證,Eric和Mic也不便提出看法和需求,就交由團隊A全權處理善後(這樣的會開了等於沒開)。
  • 團隊A亦非泛泛之輩,果然不久即推出2.0版。改進方法是將類別內所有屬性改為私有,再提供公開方法(函數)。Oliver覺得改得漂亮,問題完美解決。開始籌備慶功宴。
  • 2.0版的Tree:
    class Tree():
        '''version 2.0'''
    
        def __init__(self, breed: str, age: int, height: int):
            self.__breed = breed      # 所有attributes全改為private。
            self.__age = age
            self.__height = height
    
        def get_breed(self) -> str:          # public getter for __breed
            return self.__breed
    
        def set_breed(self, breed: str):     # public setter for __breed
            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
    
        def get_age(self) -> int:           # public getter for __age
            return self.__age
    
        def set_age(self, age: int):        # public setter for __age
            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
    
        def get_height(self) -> int:        # public getter for __height
            return self.__height
    
        def set_height(self, height: int):  # public setter for __height
            if isinstance(height, bool) or not isinstance(height, int):
                raise TypeError('樹高必須是整數。')
            if height > 200 or height < 1:
                raise Exception(f'樹高數字{height}不合理。')
            self.__height = height
    
  • 在團隊A準備慶功的同時,拿到2.0版的團隊B資深成員Mic(這時惡搞者已捲鋪蓋走人),額頭馬上長出三條線:「怎麼這樣?存取方式全變?可是我們已經用1.0版寫了n個程式,而且好些已交給客戶使用」...事情大條了。
  • 於是,Eric再度找來Oliver互相打架,不,是互相討論如何解決這個問題。最好能做到既不須更動以前寫好的code(即介面不變),又有嚴謹的防呆保護功能
  • 於是,團隊A的慶功宴無限期延後。在IT界,debug好像比吃飯重要。
  • 團隊A亂作一團,紛紛指責Oliver領導無方。這時,隊內最資深的Spring開始發揮她多年累積的經驗。她很久前看過筆者本系列鐵人文章,隱約記得OO世界好像有個名叫property的東東,似乎可以處理這類棘手難題。而且Python的property還算好使好用,也許值得一博。沒準問題可迎刃而解。
  • 在詳細閱讀本系列有關property的幾篇(含本篇)後,Spring終於恍然大悟、茅塞頓開。使用property decorator的3.0版很快便橫空出世:
    class Tree():
        '''version 3.0'''
    
        def __init__(self, breed: str, age: int, height: int):
            self.__breed = breed        # attributes依然全是private。
            self.__age = age
            self.__height = height
    
        @property
        def breed(self) -> str:
            '''The breed property(getter).'''
            return self.__breed
    
        @breed.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
        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) -> int:   
            '''The height property(getter).'''
            return self.__height
    
        @height.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
    
        def show_info(self):
            print(f'{self.breed=:10}{self.age=:<10,}{self.height=:<10}')
    
  • 3.0版的高明處,是既維持2.0版的嚴密保護和防呆功能,介面又和1.0完全相容(更不用提1.0的存取介面本就比2.0簡明直觀了)。之前用1.0版寫的code不必召回重寫,把1.0版程式設計師從改code深淵的邊緣拯救出來。
  • 現在我們來看看3.0版的表現吧:
    • 測試-1。賦予不正確的樹種(tree.breed = 'aspen'):

      try:
          tree = Tree('cedar', 2593, 37)
          tree.show_info()
          tree.breed = 'aspen'  # 表面是一般的賦值給attribute,隱藏在背後的卻是property。
      except Exception as e:
          print(f'錯誤訊息:{str(e)}')
      finally:
          tree.show_info()
      

      輸出:
      https://ithelp.ithome.com.tw/upload/images/20220927/201484855k76yp0XR1.png

    • 測試-2。樹齡給None(tree.age = None):

      try:
          tree = Tree('cedar', 2593, 37)
          tree.show_info()
          tree.age = None        # 表面是一般的賦值給attribute,隱藏在背後的卻是property。
      except Exception as e:
          print(f'錯誤訊息:{str(e)}')
      finally:
          tree.show_info()
      

      輸出:
      https://ithelp.ithome.com.tw/upload/images/20220927/20148485zU1Y9z4mxg.png

    • 測試-3。樹高為負數(tree.height = -50):

      try:
          tree = Tree('cedar', 2593, 37)
          tree.show_info()
          tree.height = -50     # 表面是一般的賦值給attribute,隱藏在背後的卻是property。
      except Exception as e:
          print(f'錯誤訊息:{str(e)}')
      finally:
          tree.show_info()
      

      輸出:
      https://ithelp.ithome.com.tw/upload/images/20220927/20148485RZymN0oQGW.png

    • 測試-4。樹種、樹齡和樹高均給合理的值:

      try:
          tree = Tree('cedar', 2593, 37)
          tree.show_info()
          tree.age = 2_000
          tree.breed = '  Phoebe '
          tree.height = 80
      except Exception as e:
          print(f'錯誤訊息:{str(e)}')
      finally:
          tree.show_info()
      

      Bingo!
      https://ithelp.ithome.com.tw/upload/images/20220927/20148485mvpfJsmKsN.png


後記:最終alextree套件成功發布至PyPI,因而打開公司知名度。周年慶時Spring榮獲特殊貢獻獎。老闆Alex龍顏大悅,加發紅利犒賞三軍。


Property的介紹到此結束,是否圓滿和正確周延則不得而知。最後給個總結:

  • 類別中的屬性attribute是「變數」,變數不可能有甚麼阻擋檢查功能。尤其Python是動態型別的語言,連型別都無法保證。
  • property雖貌似attribute,實則是方法(函數)。只要是方法,就可以在其內寫出任何簡單也好,複雜也好的判斷邏輯,來攔阻不合法的資料,或者做其他方面(例如權限、時間、存檔路徑...)的檢查和限制。
  • 改code是有風險的事。類別版本的更迭,最好能保持原有的介面,讓原來的類別使用者不必改code。至於類別內部怎麼改,則是類別設計者的事,和類別使用者無關。Property機制的誕生,部分原因大概就是為了解決這個需求吧。

註1: 欲存True卻變成1,原因是受到類別show_info()方法中的f-string{self.age=:<10,}影響。加了format specifier:<10,後,原來的True會自動轉為整數1False則轉為0。刪除此format specifier則可顯示True或False。問題是:類別的設計者原本預期age是整數而非布林。類別設計者加上format specifier並沒有錯而且是排版必需。可惜世上總有不按牌理出牌之人。


上一篇
Property Decorator
下一篇
自動產生Boilerplate Code的好幫手:Dataclass
系列文
Oops! OOPP: An Introduction to Object-Oriented Programming in Python30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言