[Python] Descriptor 與 Decorator

承上一篇「[Python] 自訂類別的參考準則」, 我們再來一起探討 Python 的類別寫法。

一個簡單的 descriptor

我們在原始範例程式中曾經看到有 @property 這樣的宣告。像這種前置詞, 在 Python 中稱為「Descriptor」(描述子)。

這個 descriptor 本身其實也是一個 class, 如下範例所示。除了像 property 這樣的內建 descriptor 外, 我們也可以自訂 descriptor 來用。

不過在這之前, 我們來看看到底 property 這個 descriptor 原本是怎麼定義的。以下是個簡化的版本:

# 程式一
class property(object):

    def __init__(self, get, set=None):
        self.__get = get
        self.__set = set

    def __get__(self, inst, objtype=None):
        return self.__get(inst)

    def __set__(self, inst, value):
        if self.__set is None:
            raise AttributeError("this attribute is read-only")
        return self.__set(inst, value)

也許出乎你的意料之外。這個 property 做的事情異常地簡單。以 getter 為例, 基本上剛才的程式中 self.__get() 這段就是被描述的欄位原來會做的事情, setter 則是 self.__set(); 我們一般會在這一段之前或之後再加入一些程式碼, 以這種方式去覆寫受描述欄位的行為。等一下會有更詳細的範例。

到底 descriptor 是做什麼用? Descriptor 的定義如下:

A descriptor is an object attribute with “binding behavior”, one whose attribute access has been overridden by methods in the descriptor protocol.

換句話說, descriptor 是一種會改變原本繫結行為的物件屬性, 類別欄位會被寫在 descriptor 的行為所覆寫。所以, 請注意它基本上是比較適用於「物件」 (特別是類別欄位); 它和適用於「方法」的 decorator 不太一樣 (雖然同樣是以前置的 @ 符號進行宣告)。但是寫久了你會發現這兩者是同樣的東西; 至少在功能上是類似的。

如程式一, 我們可以看到 @property 的本體就是一個類別 (和 decorator 不一樣 -- 在一般情況下它的本體是一個方法)。Descriptor 基本上就是實作了 __init__()、__get__() 和 __set__() 等三個方法 (其實還有一個 __delete__()) 的一個類別。但是如果比對一下我們的自訂類別, 你會發現上述幾個方法的傳入參數略有不同。

Descriptor 的幾個方法有典型的參數形式和傳出形式:

__get__(self, obj, objtype=None) # 傳回 value
__set__(self, obj, value) # 傳回 None
__delete__(self, obj) # 傳回 None

程式中 self 指的是這個 descriptor 類別本身, obj 指的是目標類別的 instance 本身。不過我們不用管參數到底是怎麼傳入的, 我們只要照著固定的形式去寫就行了。

對於從來沒使用過 descriptor 的初學者, 你可能根本看不懂我上面在講些什麼; 或許你直接拿下面的程式去實作看看, 會比較容易了解。

自訂 ReadOnly 描述子

在 Python 裡我們可以使用 @property 來指定類別屬性, 但是它並未提供一個 @readonly 來指定唯讀屬性。沒關係, 它沒有, 我們自己寫一個:

# 程式二
class ReadOnlyProperty(object):

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    # self is the ReadOnlyProperty itself, obj is the instance
    def __get__(self, obj, objtype=None): 
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("無法讀取屬性值。")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("無法指定屬性值。")
        if self.fget(obj) is not None:
            raise ValueError(self.fget.__name__ + ' 值不可重覆指定。')
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("無法刪除屬性值。")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

以上程式基本上就是 class property 的模擬版本 (原始版本似乎是用 C 寫的)。我只是把它改名做 ReadOnlyProperty, 然後在 __set__() 函式中加了這兩行:

if self.fget(obj) is not None:
    raise ValueError(self.fget.__name__ + ' 值不可重覆指定')

這樣一來, 我們就可以透過 @ReadOnlyProperty 既指定一欄位為 property, 又限制它是 read only。如果你企圖在已經指定其值的情況下又企圖指派值給它, 就會得到「XX 值不可覆指定」這個錯誤。以下我們把 Id 這個欄位宣告為 @ReadOnlyProperty, 再看結果如何:

>>> r = Retailer(LineIn='1, 新南市, 金大成')
>>> r
<Retailer Id=1 Area='新南市' Company='金大成'>
>>> r.Id=5
Traceback (most recent call last):
  File "<pyshell#132>", line 1, in <module>
    r.Id=5
  File "Retailer.py", line 31, in __set__
    raise ValueError(self.fget.__name__ + ' 值不可重覆指定')
ValueError: Id值不可重覆指定。

當然啦, Python 身為解譯式語言, 你恐怕無法要求它在「compile time」就告訴你 Id 值不能重覆指定; 它通常只能在「run time」 (或者載入時) 才會秀出錯誤。Python 裡幾乎所有的「規範」的標準寫法, 似乎就是讓它發出 exception...

此外, 我寫的這個 readonly property 和其它語言的 readonly property 不太一樣。以 C# 為例, 它所謂的 readonly, 指的是「只允許在 init() 裡寫入一次」; 但我寫的這個 readonly property, 是「只允許寫入一次」。或許你可以再把它修改成和 C# 一樣 (如果你覺得有必要的話)。

除了用來寫 ReadOnlyProperty, 我們還能拿 descriptor 來做什麼? 

其實工具就在那裡, 有需要就拿去用。例如, 假設有一個欄位是鮮少用到的, 如果它真的被指定了值 (尤其是特定的值), 或許你也可以特別把它記錄在 log 裡面。把程式二拿去改一下就行了。

如果你到這裡還是不了解 descriptor 是什麼的話, 那麼我再給它一個定性的定義。雖然它不叫做 decorator, 但是它實際上就是 decorator。差別是 decoraor 偏重於對「方法」加料, descriptor 偏重於對「物件欄位」加料。我這裡所謂的「加料」, 指的是對一個類別中的欄位, 加上一些規範或動作。以 property 描述子為例, 如果你把一個欄位描述為 property, 那麼你就不能不為這個欄位加上 setter。再以我寫的 ReadOnlyProperty 描述子為例, 如果你把一個欄位描述為 ReadOnlyProperty, 那麼你就不但要遵循 property 的規範, 這個欄位的值不能寫入第二次。依此類推。

Decorators

其實在 Python 的官方文件裡, 我找不到它對 decorator 的正式定義。不過一般來講 decorator 可以理解為

A decorator is a function that takes another function and extends the behavior of the latter function without explicitly modifying it.

如同前面提到的, 它做的事情和 descriptor 是差不多的, 只不過它適用於函式而非類別欄位而已。

先來看看一個 decorator 範例:

# 程式三
import time
def ShowDuration(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter() # Mark time now
        res = func(*args, **kwargs)
        end = time.perf_counter() # Mark time again
        print('Time spent:', '%d' % round(end - start), 'seconds.')
        print('-----------------------------')
        return res
    return wrapper

請注意, 這裡的 decorator 不一定要寫在自訂類別裡面。像我自己是把它另外寫在 Util.py 裡面, 要用到時再 import 進來用就行了。

一個典型的 decorator 宣告裡只有五行是必要的:

def BasicDecorator(func):
    def wrapper(*args, **kwargs):
        res = func(*args, **kwargs)
        return res
    return wrapper

其它幾行程式都是我自己加上去的功能。

要怎麼使用這個如程式三所示範的 decorator 呢? 只要在函式前面標示上去就行了:

@ShowDuration
def myjob():
    pass

我們回頭來看看程式三。

程式中的 func(*args, **kwargs) 指的就是上面這個 myjob() 函式。在程式三裡面, 我在它的上面記住了當時的時間 (star), 在它的下面也記住當時的時間 (end), 最後再把兩個時間相減, 就得到執行過程中花掉的時間秒數。

換句話說, 如果把這個 decorator 套用在一個相當耗時的工作上, 當這個工作執行完, 就會告訴你它在執行過程中總共花了多久時間:

>>> myjob()
Time spent: 1563 seconds.
-----------------------------

無論是 descriptor 或者 decorator, 基本上它們都套用了裝飾者模式 (Decorator Pattern)。換句話說, 它們是可以層層套疊上去的。所以, 我們在上例中 myjob() 不只可以套用 @ShowDuration, 還可以套用其它 decorator, 視需要而定。

而這個 @ShowDuration 不只可以套在 myjob() 函式上, 也可以套用在其它任何函式上, 達到程式重複利用的目的:

@ShowDuration
def myjob1():
    pass

@ShowDuration
def myjob2():
    pass

@ShowDuration
def myjob3():
    pass

你說, 這樣是不是很方便呢?

不過, 其實不管是 descriptor 或者 decorator, 它們的實作形式有很多種 (還記得嗎? 在 Python 中, 只要長得像鴨子就說是鴨子)。就像 decorator, 它其實也可以寫成 class, 不一定只能寫成函式。未來如果有需要, 再來介紹。

實作 Singleton

Singleton 一般有兩種含意; 一種是只能被 initiate 一次, 第二次以後會被拒絕; 第二種是可以被 initiate 一次, 之後就一律傳回第一個 instance。

我在這裡示範第一種做法 (只能被 initiate 一次, 之後會發出 exception), 同樣是採用 decorator:

# 程式四
def Singleton(cls):
    _inst = {}
    def getinstance(*args, **kwargs):
        if cls not in _inst:
            _inst[cls] = cls(*args, **kwargs)
        else:
            raise TypeError('Can only be initiated once for a Singleton class.')
        return _inst[cls]
    return getinstance

其實如果你把程式改一下, 很容易就變成第二種做法。不過我總覺得第二種做法似乎不太符合 Singleton 這個字的意思。

套用在類別的方法和其它 decorator 一模一樣:

@Singleton
class Retailer:
    (略)

實際執行時, 如果你企圖給它 initiate 兩次, 就會跳出錯誤:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r1
<Retailer Id=1 Area='新北市' Company='金大成'>
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
Traceback (most recent call last):
  File "<pyshell#82>", line 1, in <module>
    r2 = Retailer(LineIn='2, 台中市, 金小成')
  File "Retailer.py", line 17, in getinstance
    raise TypeError('Can only initiate once for a Singleton class.')
TypeError: Can only initiate once for a Singleton class.

如果採用第二種做法的話, r2 會指向 r1。但我個人認為這樣並不是好的做法。

我很怕未來有一天有人會跳出來指責我說「這不是 decorator! 這是 descriptor!」好啦, 隨便你啦! 你說什麼就是什麼啦!

請記得, 我這裡只是拿 Retailer 類別出來舉例而已, 並不是說 Retailer 適合套用 singleton 模式。在現實生活中, 把「經銷商」類別套用上 singleton 模式? 你確定嗎? 這樣無異是搬石頭砸自己的腳 (除非貴公司永遠只有一家經銷商。不過如果是那樣, 那也不叫做「經銷商」了不是嗎?)。

通用型 Property 定義

以上我已經介紹了 descriptor 和 decorator; 現在我們來看一下略為不一樣的做法。

我們在上一篇「[Python] 自訂類別的參考準則」中介紹了 Getter/Setter。我說過, 像 Retailer (經銷商) 這種類別, 應該會有許多屬性 (property), 而不是只有 Id, Area 和 Company 三個而已。然而, 如果要用上一篇的做法, 就非得一個屬性一個屬性慢慢地把它們「刻」出來。

但是, 畢竟有很多屬性是雷同的; 難道沒有更省事的做法嗎?

C# 沒有, Python 有!

事實上, 我們可以乾脆自己定義一個函式, 用更簡單的方法為它們做宣告:

# 程式五
def CommonProperty(name, pattern):
    localname = '_' + name

    @property
    def prop(self):
        return getattr(self, localname)

    @prop.setter
    def prop(self, value):
        if re.match(pattern, str(value).strip()) == None:
            raise ValueError(name + ' 不符合格式!')
        setattr(self, localname, value)
    
    return prop

或許你沒有想到, 如程式五所示, 像 property 這樣的 descriptor 也是可以使用在方法內部的, 完全不影響其功能。

然後我們就可以只用一行就能簡單地定義類別中各個 property 了(可同時參考上一篇的程式四寫法):

Id = CommonProperty('Id', _pattId)
Area = CommonProperty('Area', _pattArea)
Company = CommonProperty('Company', _pattCompany)

和原來的做法比較, 是不是省事多了?

我們現在來測試看看結果是否一模一樣:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
>>> r1
<Retailer Id='1' Area=' 新北市' Company=' 金大成'>
>>> r2
<Retailer Id='2' Area=' 台中市' Company=' 金小成'>

對了, 我們在本篇裡將 Id 欄位加上了 ReadOnlyProperty 描述子; 我們可以連這個步驟也省略掉, 改用這個新的 CommonProperty 來取代嗎?

可以! 稍為改一下就行了:

# 程式六
def CommonProperty(name, pattern, readonly=False):
    localname = '_' + name

    @property
    def prop(self):
        return getattr(self, localname)

    @prop.setter
    def prop(self, value):
        if readonly and getattr(self, name) is not None:
            raise ValueError('"' + name + '" 值不可重覆指定。')
        if re.match(pattern, str(value).strip()) == None:
            raise ValueError(name + ' 不符合格式!')
        setattr(self, localname, value)
    return prop

我們把上一篇中程式四 Retailer 類別略改一下:

# 程式七
class Retailer:
    
    Id = CommonProperty('Id', _pattId, readonly=True)
    Area = CommonProperty('Area', _pattArea)
    Company = CommonProperty('Company', _pattCompany)

    _Id = _Area = _Company = None
    def __init__(self, Id=None, Area=None, Company=None, LineIn=None, separator=','):
        if LineIn and isinstance(LineIn, str):
            arr = LineIn.split(separator)
            if len(arr) == len(self.keys()):
                self.Id=arr[0]
                self.Area=arr[1]
                self.Company=arr[2]
        else:
            self.Id = Id
            self.Area = Area
            self.Company = Company;

    (以下略)

請注意我們在 Id 宣告中加入了 readonly=True 具名參數。

執行結果如下:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r1
<Retailer Id='1' Area=' 新北市' Company=' 金大成'>
>>> r1.Id=5
Traceback (most recent call last):
  File "<pyshell#38>", line 1, in <module>
    r1.Id=5
  File "Retailer.py", line 22, in prop
    raise ValueError('"' + name + '" 值不可重覆指定。')
ValueError: "Id" 值不可重覆指定。

換句話說, 使用這個 CommonProperty 可以為我們省下許多工夫, 我們就不必再花時間為每個 property 寫 getter/setter 了。

計算型 Property

前面我們使用了 CommonProperty 函式把通用型的屬性包起來; 但對於計算型 (Computed) 的屬性, 該怎麼處理?

所謂的計算型屬性, 意思是這種屬性是根據其它的值即時計算出來的, 並不是一個死欄位。例如當我們要計算員工薪資時, 會把本薪扣掉所得稅算出來, 這個值才是真正會匯到對方薪資帳戶的錢。像這種值, 我們通常會另外設計一個方法來做計算, 但是我們也可以設計一個計算型的屬性。把這個值設計為屬性, 在許多情況下會方便計多, 例如透過 ToDict() 方法把資料匯出, 而無需更改程式的原來架構。

以本篇使用的程式做範例,  假設我們在既有欄位(Id, Area, Company)之外, 要額外設計一個 AltCompany 欄位, 其呈現方式是 "(Id)Company" 的型式 (像「(1)金大成」這樣); 這裡 Id 和 Company 都是既有欄位, 我們只是簡單地把它們湊起來而已。當然, 我們可以找出很多種做法來實作這樣的功能, 但到底如何用屬性來做呢?

在原始程式裡, Id 和 Company 兩個欄位都是透過 CommonProperty 函式來做的, 而 AltCompany 欄位顯然無法那樣做; 我們只能使用不同的寫法:

@property
def AltCompany(self):
    if self.Id and self.Company:
        return '(%s)%s' % (self.Id, self.Company)
    return None;

最後版本的 Retailer.py 如下:

# 程式八

import re

_pattId = r'^[0-9]{1,6}$'
_pattArea = r'^[\u4e00-\u9fa5]{2,5}$'
_pattCompany = r'^[\u4e00-\u9fa5]{2,12}$'

def CommonProperty(name, pattern=None, readonly=False):
    localname = '_' + name

    @property
    def prop(self):
        return getattr(self, localname)

    @prop.setter
    def prop(self, value):
        if readonly and getattr(self, name) is not None:
            raise ValueError('"' + name + '" 值不可重覆指定。')
        if pattern and re.match(pattern, str(value).strip()) == None:
            raise ValueError(name + ' 不符合格式!')
        setattr(self, localname, value)
    return prop

class Retailer:    
    Id = CommonProperty('Id', _pattId, readonly=True)
    Area = CommonProperty('Area', _pattArea)
    Company = CommonProperty('Company', _pattCompany)

    @property
    def AltCompany(self):
        if self.Id and self.Company: # 防呆
            return '(%s)%s' % (self.Id, self.Company)
        return None;

    _Id = _Area = _Company = None
    def __init__(self, Id=None, Area=None, Company=None, LineIn=None, separator=','):
        if LineIn and isinstance(LineIn, str):
            arr = LineIn.split(separator)
            if len(arr) == len(self.keys()):
                self.Id=arr[0]
                self.Area=arr[1]
                self.Company=arr[2]
        else:
            self.Id = Id
            self.Area = Area
            self.Company = Company;

    # For comparer
    def __eq__(self, other):
        if isinstance(other, int):
            return self.Id == other
        return self.Id == other.Id

    def __lt__(self, other):
        if isinstance(other, int):
            return self.Id < other
        return self.Id < other.Id

    def __gt__(self, other):
        if isinstance(other, int):
            return self.Id > other
        return self.Id > other.Id
        
    # Used for str() function
    def __str__(self):
        return str(self.Id)
        
    # Used for repr() function
    def __repr__(self):
        #return str(self.Id)
        return '<Retailer Id=%r Area=%r Company=%r>' % (self.Id, self.Area, self.Company)

    # Used for .. in .. statement.
    def __contains__(self, keyword):
        return  str(keyword).strip() in str(self.Id)

    def keys(self):
        return [
            'Id',
            'Area',
            'Company']

    def values(self):
        return [
          self.Id,
          self.Area,
          self.Company
        ]

    def ToDict(self):
        return dict(zip(self.keys(), self.values()));

    @classmethod
    def FromDict(cls, d):
        if not isinstance(d, dict): # 防呆
            return None;
        dk = list(d.keys())
        dv = list(d.values())
        r = cls()
        for i in range(len(dk)):
            if dk[i] == 'Id':
                r.Id = int(dv[i])
            if dk[i] == 'Area':
                r.Area = dv[i]
            if dk[i] == 'Company':
                r.Company = dv[i]
        return r

 


Dev 2Share @ 點部落