iT邦幫忙

2023 iThome 鐵人賽

DAY 7
0
Software Development

Python十翼:與未來的自己對話系列 第 7

[Day07] 次翼 - Decorator:@func to class

  • 分享至 

  • xImage
  •  

今天我們分享decorator function裝飾於class上的情況。本日接下來內容,會以decorator來作為decorator function的簡稱。

相較於[Day05][Day06]會回傳新的function或是instanceFunc @ Class這種function裝飾class的情況,較多情況是mutate接收的cls並回傳,而不產生新的class

至於實際上該如何使用呢?Python內建的total_ordering是一個絕佳的例子。今天我們先欣賞total_ordering的源碼後,再來做一個實例練習一下。

total_ordering源碼

客製化class的排序是依靠各種rich comparisondunder methodtotal_ordering可以幫助我們在只實作__lt____le____gt____ge__四種方法其中之一加上__eq__的情況下,使得客製化class能擁有所有comparison的功能。

  • 首先Python定義一個_convert dict,以__lt____le____gt____ge__四種方法的名稱為key,而value則為一個list內含三個tuple,代表剩餘三種需要由Python輔助完成的方法名稱及方法。
_convert = {
    '__lt__': [('__gt__', _gt_from_lt),
               ('__le__', _le_from_lt),
               ('__ge__', _ge_from_lt)],
    '__le__': [('__ge__', _ge_from_le),
               ('__lt__', _lt_from_le),
               ('__gt__', _gt_from_le)],
    '__gt__': [('__lt__', _lt_from_gt),
               ('__ge__', _ge_from_gt),
               ('__le__', _le_from_gt)],
    '__ge__': [('__le__', _le_from_ge),
               ('__gt__', _gt_from_ge),
               ('__lt__', _lt_from_ge)]
}
  • 這些方法可以由數學上推論而得,我們舉_gt_from_lt為例,如何在有__lt____eq__註1)的情況下推得__gt__。由Python註解可知a > b相當於not (a < b) a != b的,而後者都是我們可以使用的方法。靠著已知的操作組合出新的comparison功能,total_ordering是不是相當巧妙的設計呢!
def _gt_from_lt(self, other):
    'Return a > b.  Computed by @total_ordering from (not a < b) and (a != b).'
    op_result = type(self).__lt__(self, other)
    if op_result is NotImplemented:
        return op_result
    return not op_result and self != other
  • 最後我們觀察total_ordering內部實作邏輯。
    • 首先Python確認,我們「自己」實作了哪幾種不是由object繼承而來的comparison方法,然後將找到的方法名稱存在roots這個set內。如果沒有找到的話,代表我們連最少需要一種的要求都沒達到,則raise ValueError
    • 接著如果有找到的話,Python會依照__lt__ => __le__ => __gt__ => __ge__的喜好順序,從_convert挑出剩下三種,有可能需要Python幫忙實作的方法。接著對這些方法打一個迴圈,如果方法名不在roots內,則使用setattr,將Python幫忙實作的方法,指給cls。這也代表Python的思維是,盡量使用「使用者實作的comparison」,除非沒有給予時,才給予幫助。
    • 最後回傳cls
def total_ordering(cls):
    """Class decorator that fills in missing ordering methods"""
    # Find user-defined comparisons (not those inherited from object).
    roots = {op for op in _convert if getattr(cls, op, None) is not getattr(object, op, None)}
    if not roots:
        raise ValueError('must define at least one ordering operation: < > <= >=')
    root = max(roots)       # prefer __lt__ to __le__ to __gt__ to __ge__
    for opname, opfunc in _convert[root]:
        if opname not in roots:
            opfunc.__name__ = opname
            setattr(cls, opname, opfunc)
    return cls

注意事項

理論上 您不需要實作__eq__total_ordering也能提供一定程度的功能,因為object是預設有實作__eq__的。但object__eq__預設是比較兩者是否為同一個obj,這可能不是您預期的行為。

# 01中:

  • p1p2Pointinstance,而Point實作有__lt____eq__並搭配total_ordering,這是一個標準的範例,所以p1 == p2會如預期是True
  • p3p4PointWithoutCustomEqinstance,而PointWithoutCustomEq只實作並搭配total_ordering,此時p3 == p4會是False,因為在object__eq__判定兩個並不是同一個obj。的確兩個不是同一個obj,一個是p3,一個是p4,只是兩個obj都是由PointWithoutCustomEq生成而已。
# 01
from functools import total_ordering


@total_ordering
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __lt__(self, other):
        if isinstance(other, type(self)):
            return (self.x, self.y) < (other.x, other.y)
        return NotImplemented

    def __eq__(self, other):
        if isinstance(other, type(self)):
            return (self.x, self.y) == (other.x, other.y)
        return NotImplemented


@total_ordering
class PointWithoutCustomEq:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __lt__(self, other):
        if isinstance(other, type(self)):
            return (self.x, self.y) < (other.x, other.y)
        return NotImplemented


if __name__ == '__main__':
    p1, p2 = Point(0, 0), Point(0, 0)
    print(p1 == p2)  # True

    p3, p4 = PointWithoutCustomEq(0, 0), PointWithoutCustomEq(0, 0)
    print(p3 == p4)  # False!!!

所以實務上 還是建議依照Python docs的指示,自己實作__eq__

實例說明

情境

假設您有一個個人的open source project,裡面實作了一些自己常用的小工具,其中有些重要的邏輯是要call RustZig來完成的。由於您對這兩種語言還在高速學習中,所以核心的程式碼常在變動。但好在使用者除了自己之外,就是同事等親朋好友,所以維護起來沒什麼大問題。突然有一天這個project被大神推薦了,使用者開始大量增加。雖然您有提供public interface給大家呼叫,但由於還在建置階段,支援的範圍不夠全面。此時,大家發現有些底層RustZig實作的邏輯非常好用,可以直接呼叫,就不理會這是underscore開頭的private function,直接拿來用,結果就產生各式各樣的問題,塞爆您的github issue跟pull request。

於是您決定在某版本後,將這些部份打包成其它library,並從現在開始,當使用者呼叫這些函數時,報給他們Deprecation Warning

這些需要報Warningfunction都在class內且都是由_call開頭,您開始思考該怎麼樣完成這件事呢?

  • 每個function都進去改 => 應該有更好的方法吧...
  • metaclasses=> 但是某版本之後就不需要這個功能了,metaclasses會不會殺雞用牛刀了呢?
  • ...

思考良久,您決定使用decorator來裝飾所有需要報Warningclass。這樣在某版本後,只要移除這些加上的decorator就好。

解題思路

  • 總共需要寫兩個decorator
    • 第一個命名為my_warn,是用來裝飾在class之上。
    • 第二個命名為warn_using_private_func,是用來裝飾在需要報Warningfunction之上。

my_warn實作

my_warn接收cls為變數,接下來我們對cls.__dict__打一個迴圈,如果該objcallable且名字為_call開頭,則是我們要裝飾的對象(註2)。我們使用setattr重新將cls.name設定給裝飾過後的obj(即warn_using_private_func(obj))後,返回cls

#02
def my_warn(cls):
    for name, obj in cls.__dict__.items():
        if callable(obj) and name.startswith('_call'):
            setattr(cls, name, warn_using_private_func(obj))
    return cls

warn_using_private_func

warn_using_private_func是一個基本的decorator function,我們於真正呼叫底層function前(fn(*args, **kwargs)),透過warnings.warn給出一個DeprecationWarning,並印出我們客製的訊息。

# 02
import warnings
from functools import wraps
from textwrap import dedent


def warn_using_private_func(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        warn_msg = dedent('''
            Users are discouraged from directly invoking this kind of private function 
            starting with `_call`, as it is scheduled for removal in version 0.51.''')
        warnings.warn(warn_msg, DeprecationWarning)
        return fn(*args, **kwargs)
    return wrapper

實際使用

#02

@my_warn
class MyClass:
    def _call_rust(self):
        '''This function will invoke some Rust code'''

    def _call_zig(self):
        '''This function will invoke some Zig code'''


if __name__ == '__main__':
    my_inst = MyClass()
    my_inst._call_rust()
    my_inst._call_zig()

直接將my_warn裝飾於MyClass上。此時,當我們使用my_inst._call_rust()my_inst._call_zig()時,就會觸發DeprecationWarning

/this/is/the/python/filepath/xxx.py DeprecationWarning: 
Users are discouraged from directly invoking this kind of private function 
starting with `_call`, as it is scheduled for removal in version 0.51..
  warnings.warn(warn_msg, DeprecationWarning)

如果您仔細觀察會發現,DeprecationWarning只出現一句。這其實是Python的設計,可以參考Python docs

... Repetitions of a particular warning for the same source location are typically suppressed. ...

事實上,這個設計的確是我們大部份情況下想要的行為。

# 03是使用繼承的情況,我們使用warnings.simplefilter('always', DeprecationWarning)來改變Python預設的行為,此時Warning會出現兩次,相信這不是您希望的行為。

# 03
...
# import及warn_using_private_func同`# 02`
warnings.simplefilter('always', DeprecationWarning)


@my_warn
class MyClass:
    def _call_rust(self):
        '''This function will invoke some Rust code'''


@my_warn
class MySubClass(MyClass):
    def _call_rust(self):
        '''This function will invoke some Rust code'''
        super()._call_rust()


if __name__ == '__main__':
    my_inst = MySubClass()
    my_inst._call_rust()  # warning message will show 2 times

備註

註1:由於__ne__預設__eq__結果的相反,所以我們實際上是擁有三種comparison method

註2:實務上,您可能要處理五花八門的型態,例如propertyclass methodstatic methodclass內的class等等...。

Code

本日程式碼傳送門


上一篇
[Day06] 次翼 - Decorator:@class to func
下一篇
[Day08] 三翼 - property:核心原理與基本型態
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言