iT邦幫忙

2023 iThome 鐵人賽

DAY 6
0
Software Development

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

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

  • 分享至 

  • xImage
  •  

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

另外,有些於decorator function提過的細節,將不特別重複,直接入進本日重點。

核心概念

decorator的核心概念為接受一個function,從中做一些操作,最後返回一個classinstance。一般來說,返回的instance是個callable,會接收與原function相同的參數,並返回相同的結果,但卻能具有decorator額外賦予的功能。

基本型態

基本型態1

# 01
class dec:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)


@dec
def my_func(*args, **kwargs):
    pass
  • 定義一個decorator,名為dec。其接收一個function,但卻返回一個dec生成的instance,稱作dec_instance。接收的function會被指定為dec_instanceinstance variable self.func
  • 定義一個被dec裝飾的function,名為my_func,其可接受*args**kwargs

藉由decoratormy_func已經從原先的my_func變成dec_instance了。由於__call__註1)與原先的my_func接收相同的參數(即*args**kwargs),所以裝飾前後,my_func的呼叫方式是一致的。

當呼叫my_func時,實際上是在呼叫dec_instance。舉例來說,此時的my_func(1, 2),相當於呼叫dec_instance(1, 2),即dec_instance.__call__(1, 2)。而dec_instance.__call__則返回原先傳入的self.func搭配上args = (1, 2),kwargs = {}這些參數的計算結果。

基本型態2(加上__get__

# 01code有個潛在問題,就是當它裝飾在class內的function時,如01a,會raise TypeError

# 01a
class dec:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)


class MyClass:
    @dec
    def my_func(self, *args, **kwargs):
        pass
    
    
if __name__ == '__main__':
    my_inst = MyClass()
    my_inst.my_func()  # TypeError 

01a的錯誤訊息為MyClass.my_func() missing 1 required positional argument: 'self'。這是怎麼一回事呢?

我們一樣回到核心原理開始思考,雖然MyClass中的my_func原本是個function,但經過dec的裝飾後,my_func已經變作dec_instance,所以上述的MyClass可視為下面這個寫法的簡潔版(記得@dec是語法糖嗎?)。

# 01a
class MyClass:
    def my_func(self, *args, **kwargs):
        pass
    my_func = dec(my_func)

這麼一來,很清楚的看出my_func是位於MyClass中,由dec所建立的一個dec_instance。此時my_inst.my_func(),相當於dec_instance作為一個callable來呼叫dec_instance.__call__(),而其__call__需接收一個self參數,及選擇性給予的*args**kwargs。由於我們沒有傳參數給__call__,所以Python提醒我們最少需要給self參數,才能呼叫成功。

或許您還是有疑惑,為什麼我們明明使用my_inst.my_func(),為什麼my_inst沒有自動傳遞給my_func,作為第一個參數self呢?那是因為function是一種non-data descriptor,其具備有__get__,並於其內使用MethodType來將descriptorinstance與呼叫其的instance bound在一起,所以才會有我們習慣的自動傳遞instancefunction中,作為第一個參數self的行為。如果有些不明白的話,我們會於後續導讀Descriptor HowTo Guide的部份再詳談。

重點是,當使用decorator class裝飾class中的function,實作__get__可以讓它用起來,就像是一般的instance method註2),如# 02

# 02
from types import MethodType


class dec:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return MethodType(self, instance)


class MyClass:
    @dec
    def my_func(self, *args, **kwargs):
        pass


if __name__ == '__main__':
    my_inst = MyClass()
    my_inst.my_func()  # ok

dec__get__中的MethodType(self, instance)會幫忙將dec_instancemy_inst bound在一起。

基本型態3(加上functools.update_wrapper)

基本型態1基本型態2的寫法皆會喪失被裝飾functionmetadata。一個折衷的辦法是將這些metadata更新到dec_instance.__dict__,即__init__中的update_wrapper(self, self.func)

# 03
from functools import update_wrapper
from types import MethodType


class dec:
    def __init__(self, func):
        self.func = func
        update_wrapper(self, self.func)

    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return MethodType(self, instance)

實例說明

# 04透過decoratorlogging一些資訊:

# 04
import logging
from functools import update_wrapper
from types import MethodType


class log:
    def __init__(self, func):
        self.func = func
        update_wrapper(self, self.func)

    def __call__(self, *args, **kwargs):
        logging.info(f'__call__ is called, {self.func=}, {args=}, {kwargs=}')
        return self.func(*args, **kwargs)

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return MethodType(self, instance)


@log
def add(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


class MyClass:
    @log
    def add(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    my_inst = MyClass()
    print(add(1, 2))  # 3
    print(my_inst.add(1, 2))  # 3

__call__中,我們加了一行logging.info來協助記錄每次log生成的instance被呼叫時,其實際使用的funcargskwargs

INFO:root:__call__ is called, self.func=<function add at 0x0000015CBE110C20>, args=(1, 2), kwargs={}
3
INFO:root:__call__ is called, self.func=<function MyClass.add at 0x0000015CBE88A160>, args=(<__main__.MyClass object at 0x0000015CBE884950>, 1, 2), kwargs={}
3

可以順便觀察metadata更新的狀況。

add=<__main__.log object at 0x0000015CBE4CF910>
add.__module__='__main__'
add.__name__='add'
add.__doc__='Take two integers and return their sum.'
add.__qualname__='add'
add.__annotations__={'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
add.__dict__={'func': <function add at 0x0000015CBE110C20>, '__module__': '__main__', '__name__': 'add', '__qualname__': 'add', '__doc__': 'Take two integers and return their sum.', '__annotations__': {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}, '__wrapped__': <function add at 0x0000015CBE110C20>}

my_inst.add=<bound method MyClass.add of <__main__.MyClass object at 0x0000015CBE884950>>
my_inst.add.__module__='__main__'
my_inst.add.__name__='add'
my_inst.add.__doc__='Take two integers and return their sum.'
my_inst.add.__qualname__='MyClass.add'
my_inst.add.__annotations__={'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
my_inst.add.__dict__={'func': <function MyClass.add at 0x0000015CBE88A160>, '__module__': '__main__', '__name__': 'add', '__qualname__': 'MyClass.add', '__doc__': 'Take two integers and return their sum.', '__annotations__': {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}, '__wrapped__': <function MyClass.add at 0x0000015CBE88A160>}

decorator factory(本身可接收參數)

當我們希望有一個flag來控制這個decorator是否要logging,可以寫成# 05

# 05
import logging
from functools import wraps


class log:
    def __init__(self, to_log=True):
        self.to_log = to_log

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if self.to_log:
                logging.info(
                    f'__call__ wrapper is called, {func=}, {args=}, {kwargs=}')
            return func(*args, **kwargs)
        return wrapper


@log()
def add(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


class MyClass:
    @log()
    def add(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    my_inst = MyClass()
    print(add(1, 2))  # 3
    print(my_inst.add(1, 2))  # 3

decorator factory一樣,可施加於一般function,及class內的function

  • @log()中的log()會先得到loginstance,稱作log_instace。由於log有實作__call__,所以log_instacecallable
  • 此時@log()相當於@log_instance(add)(to_log的資訊已傳至self.to_log)。由於__call__是一個instance methodlog_instance將自動傳遞至__call__作為第一個參數self
  • 最後__call__會回傳wrapper,其接受參數與add相同,並會返回相同結果,只是額外針對self.to_log的值來決定是否進行logging。所以最終呼叫add相當於呼叫wrapper
  • 由於最終返回的是wrapper function,不再是基本型態中的instance,所以我們可以直接像在decorator function中一樣,使用較為方便的functools.wraps

值得一提的是,這個情況我們不需替log實作__get__,即可施加於class內的function。原因是這次我們返回的是wrapper,其本身是functionfunction本身就有實作__get__,所以當使用my_inst.add(1, 2),其會返回一個boundaddmy_instMethodType instance來接收12這兩個參數。

實例說明

# 06
import logging
from functools import wraps
from numbers import Real
from typing import get_type_hints


class log:
    def __init__(self, *, to_log=True, validate_input=True):
        self.to_log = to_log
        self.validate_input = validate_input

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if self.to_log:
                logging.info(
                    f' `wrapper` is called, {func=}, {args=}, {kwargs=}')
            if self.validate_input:
                n = len(args) + len(kwargs)
                type_hints = get_type_hints(func)
                if n and n+1 > len(type_hints):  # return is included in type_hints
                    if self.to_log:
                        logging.error(
                            f'Annotations={type_hints}, {args=}, {kwargs=}')
                    raise TypeError('Some annotations might be missing.')

                if args and not all(isinstance(arg, type_)
                                    for arg, type_ in zip(args, type_hints.values())):
                    if self.to_log:
                        logging.error(
                            f'Annotations={type_hints}, {args=}, {kwargs=}')
                    raise TypeError(
                        f'Possible incorrect type assignment in {args=}')

                if kwargs and not all(isinstance(kw_value, type_)
                                      for name, type_ in type_hints.items()
                                      if (kw_value := kwargs.get(name))):
                    if self.to_log:
                        logging.error(
                            f'Annotations={type_hints}, {args=}, {kwargs=}')
                    raise TypeError(
                        f'Possible incorrect type assignment in {kwargs=}')

            result = func(*args, **kwargs)

            if self.validate_input:
                expected_return_type = type_hints['return']
                if not isinstance(result, expected_return_type):
                    logging.warning(
                        f' Return value: {result}(type={type(result)}) is not an '
                        f'instance of {expected_return_type}')
            if self.to_log:
                logging.info(' `wrapper` is finished.')
            return result
        return wrapper


@log(to_log=True, validate_input=True)
def add(a: Real, b: Real) -> Real:
    """Take two reals and return their sum."""
    return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
    r = add(1.2, b=2.3)
    print(r, type(r))  # 3.5, float
INFO:root: `wrapper` is called, func=<function add at 0x0000024A7C3C4EA0>, args=(1.2,), kwargs={'b': 2.3}
INFO:root: `wrapper` is finished.
3.5 <class 'float'>

# 06[Day05]# 07寫法不同的地方,只在:

  • # 06將接收的參數邏輯放在__init__
  • # 06wrapper放在__call__

常用型態(@dec | @dec())

如果想要同時能夠使用@log@log()兩種語法,勢必要面對回傳值有時是function,有時是instance的情況,所以相關metadata的處理也要記得分開處理,我們做了一些嘗試(註3)。

相較之下,我們會建議使用一個function來包住一個decorator class,如# 07所示。原理其實和decorator function# 08差不多。

# 07
import logging
from functools import wraps


class log:
    def __init__(self, *, to_log=True):
        self.to_log = to_log

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if self.to_log:
                logging.info(
                    f'__call__ wrapper is called, {func=}, {args=}, {kwargs=}')
            return func(*args, **kwargs)
        return wrapper


def logf(func=None, /, *, to_log=True):
    if func is None:
        return log(to_log=to_log)
    return log(to_log=to_log)(func)


@logf()
def add1(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


@logf
def add2(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


class MyClass:
    @logf()
    def add1(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b

    @logf
    def add2(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    print(add1(1, 2))  # 3
    print(add2(1, 2))  # 3
    my_inst = MyClass()
    print(my_inst.add1(1, 2))  # 3
    print(my_inst.add2(1, 2))  # 3

若檢查所有metadata,也都有一起更新。

INFO:root:__call__ wrapper is called, func=<function add1 at 0x000001D48F3C4E00>, args=(1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function add2 at 0x000001D48F886200>, args=(1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function MyClass.add1 at 0x000001D48F8863E0>, args=(<__main__.MyClass object at 0x000001D48F8886D0>, 1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function MyClass.add2 at 0x000001D48F886520>, args=(<__main__.MyClass object at 0x000001D48F8886D0>, 1, 2), kwargs={}
3

當日筆記

  • 使用基本型態1時,decorator可施加於一般的function
  • 使用基本型態2時,decorator可施加於一般的functionclass內的function上。
  • 使用基本型態3時,decorator可施加於一般的functionclass內的function上,且被裝飾functionmetadata會更新至decorator生成的instance內。
  • decorator factory最終會返回的是function,其本身已具有__get__,所以不用額外處理。
  • 使用常用型態時,建議使用一個function包住一個decorator class使用,我們覺得會比註3的寫法更簡單優雅。

備註

註1:當class內有實作__call__,該class生成的instance則為callable

註2:其實對於classmethodstaticmethod上,decorator class也是可以用的,只是必須注意順序,要得先@log再加上@classmethod@staticmethod

# 101
class MyClass:
    @classmethod
    @log
    def class_method(cls):
        pass

    @staticmethod
    @log
    def static_method():
        pass

註3:# 102中,除了需要同時考慮兩種邏輯,還要記得實作__get__,相比於# 07的寫法複雜不少。

# 102
import logging
from functools import update_wrapper
from types import MethodType


class log:
    def __init__(self, func=None, /, *, to_log=True):
        self.func = func
        self.to_log = to_log
        if func is not None:
            update_wrapper(self, func)

    def _make_wrapper(self, func):
        def wrapper(*args, **kwargs):
            if self.to_log:
                logging.info(
                    f'__call__ inner is called, {func=}, {args=}, {kwargs=}')
            return func(*args, **kwargs)
        return wrapper

    def __call__(self, *args, **kwargs):
        if self.func is None:
            func = args[0]
            wrapper = self._make_wrapper(func)
            update_wrapper(wrapper, func)
            return wrapper
        else:
            func = self.func
            wrapper = self._make_wrapper(func)
            return wrapper(*args, **kwargs)

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return MethodType(self, instance)

Code

本日程式碼傳送門


上一篇
[Day05] 次翼 - Decorator:@func to func
下一篇
[Day07] 次翼 - Decorator:@func to class
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言