[Effective Python] 情境:優先使用輔助類別而非使用字典或元組來管理記錄

[Effective Python] 情境:優先使用輔助類別而非使用字典或元組來管理記錄

Python的內鍵字典型別(Dictionary Type)對於維護一個物件生命期的動態內部狀態來說,非常useful。

舉例來說,,假如我們有一個銀行帳本,須要記錄客戶的存款與記錄,但我們事先並不知道他們的姓名。

那我們可能設計以下類別,透過字典來將這些名字記錄起來:

class BankBook( object ):
    def __init__(self):
        self._history = {}

    def add_customer(self, name):
        self._history[name] = []

    def save_money(self, name, amount):
        self._history[name].append(amount)

    def draw_money(self, name, amount):
        self._history[name].append(-1 * amount)

    def show_balance(self, name):
        moneys = self._history[name]
        return sum(moneys)

這個類別的使用方式很簡單:


book = BankBook()
book.add_customer('paul')
book.save_money('paul', 1200)

book.save_money('paul', -200)

print(book.show_balance('paul'))

字典因為太容易使用,以至於有過度擴充它們而寫出脆弱code的風險

例如,我們面臨了一個新的需求而要擴充這個類別,誐們希望記錄不同幣別的帳戶,我們可以修改看看,試著將_history也變成字典,用於區分不同的幣別


class CurrencyBankBook( object ):
    def __init__(self):
        self._history = {}

    def add_customer(self, name):
        self._history[name] = {} #原本串列變成字典

    def save_money(self, name, currency_name , amount):
        by_currency = self._history[name]
        currency_book_list = by_currency.setdefault(currency_name, [])
        currency_book_list.append(amount)

    def draw_money(self, name, currency_name, amount):
        self.save_money(name, currency_name, -1 * amount)

    def show_balance(self, name, currency_name):
        if not currency_name:
            raise ValueError("currency_name can't be empty")
        currency_book_list = self._history[name]
        if isinstance(currency_book_list, dict):
            if currency_name in currency_book_list.keys():
                return sum(currency_book_list[currency_name])

        raise ValueError( "currency_name not exist!!" )

這看起來變化不大,只是在於history的處理與查找上增加了一點複雜度,但仍在可以處理的範圍內,使用方式依然很簡單。

book = CurrencyBankBook()
book.add_customer('paul')
book.save_money('paul', 'ntd', 1200)
book.draw_money('paul', 'ntd', 1000)
book.save_money('paul', 'us', 300)
book.draw_money('paul', 'us', 200)

print("ntd-balance:%s" % book.show_balance('paul', 'ntd'))
print("us-balance:%s" % book.show_balance('paul', 'us'))

現在想像一下,如果我們的需求再次改變了,目前我們想要記錄每一次存款的時間點與備註,像網路銀行查詢帳戶記錄的時候,都會有每個交易時間點的餘額資訊

實作這種功能的方式之一就是變更最內層的字典,將幣別映射到的金額使用元組(tuple)來作為值。

我們改寫類別如下:

class AdvCurrencyBankBook( object ):
    def __init__(self):
        self._history = {}

    def add_customer(self, name):
        self._history[name] = {} #原本串列變成字典

    def save_money(self, name, currency_name , amount, time=None, memo=""):
        by_currency = self._history[name]
        currency_book_list = by_currency.setdefault(currency_name, [])
        currency_book_list.append((amount, datetime.now() if time == None else time, memo))

    def draw_money(self, name, currency_name, amount, time=None, memo=""):
        self.save_money(name, currency_name,-1 * amount, time, memo)

    def show_balance(self, name, currency_name, balance_time=datetime(2017, 9, 22, 10, 55, 00)):
        if not currency_name:
            raise ValueError("currency_name can't be empty")
        currency_book_list = self._history[name]
        if isinstance(currency_book_list, dict):
            if currency_name in currency_book_list.keys():
                return sum([amount for amount, datetime, memo in currency_book_list[currency_name] if datetime <= balance_time])
        raise ValueError( "currency_name not exist!!" )

book = AdvCurrencyBankBook()
book.add_customer('paul')
book.save_money('paul', 'ntd', 1200, time=datetime(2017, 9, 21, 10, 55, 00))
book.draw_money('paul', 'ntd', 1000, time=datetime(2017, 9, 22, 10, 55, 00))
book.save_money('paul', 'us', 300, time=datetime(2017, 9, 21, 10, 55, 00))
book.draw_money('paul', 'us', 200, time=datetime(2017, 9, 22, 10, 55, 00))

print("ntd-balance:%s" % book.show_balance('paul', 'ntd', balance_time=datetime(2017, 9, 21, 23, 59, 59)))
print("us-balance:%s" % book.show_balance('paul', 'us', balance_time=datetime(2017, 9, 21, 23, 59, 59)))

雖然對於save_money跟draw_money來說,只是多加一個參數,並把值變成一個元組(tuple)。但現在balance開始要用迴圈開始查找條件資料。這個類別使用方式也更加困難了。

包含位置引數所代表的意義,並不容易弄清楚,例如:book.draw_money(‘paul’, ‘us’, 200, datetime(2017, 9, 22, 10, 55, 00))

 

當我們看到這樣的複雜情況出現時,就知道該是時候放棄字典與元組了,應是改為使用型別階層架構的類別的時候了

當我們發現記錄工作變得複雜,就將它拆解成多個類別。這讓我們得以提供定義更好的介面,用較好的方式來封裝資料。

重構一下吧!

Effective Python建議我們可以從依存樹(dependency tree)的底部開始改寫為類別,也就是單一次交易,對於這種簡單的資訊來說,使用一個類別似乎太過,而一個元組(tuple)看起來好像比較適當

if isinstance(currency_book_list, dict):
    if currency_name in currency_book_list.keys():
        return sum([amount for amount, datetime, _ in currency_book_list[currency_name] if datetime <= balance_time])

問題在於一般的元組是位置型的指派方式。當我們想要更多的資訊關聯到幣別帳戶,像是我們已經定義的memo備註,就得重新再改寫那個三元組。讓程式碼知有n個項目存在
在此我們可以使用_(底線變數名稱為,來unpack並直接skip掉(撈英文?)

這種將元組擴充得來越長的模式,就類似越來越多層的字典。只要發現元組的長度超過了二元組,就應該考慮namedtuple了
collections模組內的namedtuple可以位置引數或關鍵字引數的方式來建構,讓欄位可以以具名的屬性來存取

import collections
Transaction = collections.namedtuple('Transaction', ('amount', 'time', 'memo'))

def save_money(self, name, currency_name , amount, time=None, memo=""):
		by_currency = self._history[name]
		currency_book_list = by_currency.setdefault(currency_name, [])
		currency_book_list.append(Transaction(amount, datetime.now() if time == None else time, memo))

def show_balance(self, name, currency_name, balance_time=datetime(2017, 9, 22, 10, 55, 00)):
	if not currency_name:
		raise ValueError("currency_name can't be empty")
		currency_book_list = self._history[name]
		total = 0
		if isinstance(currency_book_list, dict):
			for data in currency_book_list[currency_name]:
				if data.time <= balance_time:
					total += data.amount
					return total
		raise ValueError( "currency_name not exist!!" )

namedtuple比tuple更具體,使用上可以寫出較易維護的程式碼,但是仍有其限制

  1. 無法為namedtuple類別指定預設的引數。如果資料特性有許多選擇性的特性。那這就會使得它們變的過於笨重。
  2. namedtuple實體的屬性仍然可透過數值的索引與迭代來存取。特別是在對外提供的api中,這可會導致非預期的使用,讓你在之後更能以改寫成一個真正的類別。
from datetime import datetime

import collections

Transaction = collections.namedtuple( 'Transaction', ('amount', 'time', 'memo') )

class Account( object ):
    def __init__(self):
        self._history = []

    def save_money(self, amount, time=None, memo=""):
        self._history.append( Transaction( amount, datetime.now() if time == None else time, memo ) )

    def draw_money(self, amount, time=None, memo=""):
        self.save_money( -1 * amount, time, memo )

    def get_balance(self, balance_time=datetime.now()):
        total = 0
        for transaction in self._history:
            if transaction.time <= balance_time:
                total += transaction.amount
        return total


class Customer( object ):
    def __init__(self):
        self.__account = {}

    def currency_account(self, currency_name):
        if currency_name not in self.__account:
            self.__account[currency_name] = Account()
        return self.__account[currency_name]

    def show_balance(self):
        for name, account in self.__account.items():
            print( "%s-balance:%s" % (name, account.get_balance(datetime.now())) )


class ObjectBankBook( object ):
    def __init__(self):
        self.__customer = {}

    def customer(self, name):
        if name not in self.__customer:
            self.__customer[name] = Customer()
        if isinstance( self.__customer[name], Customer ):
            return self.__customer[name]

這些類別的行數幾乎是之前實作的double,但程式碼比較容易閱讀與維護。

book = ObjectBankBook()
paul = book.customer( 'paul' )
usAccount = paul.currency_account('us')
usAccount.save_money(1200)
usAccount.draw_money(300)

ntdAccount = paul.currency_account('ntd')
ntdAccount.save_money(400)
ntdAccount.draw_money(200)
paul.show_balance()

us-balance:900
ntd-balance:200

註:如果必要,你可以撰寫回溯相容的方法來協助將舊API的使用方式移植到物件階層架構的新方式。

 

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *