【个人笔记】浅析Python修饰器(二)

91 阅读7分钟

57681432_p0.jpg

书接上回

实现对属性编辑操作的拦截

上回中,我们谈到在Python中利用基于类的描述器(descriptor)和函数修饰器(decorator),可以实现针对对象属性访问的拦截操作。那么我们很容易想到,既然可以拦截对属性的访问操作,那么也就一定能够实现针对属性的编辑操作的拦截。

下面我先直接上具体的代码:

class my_property:

    def __init__(self, *args, **kwargs):
        pass

    def __get__(self, obj, cls):
        return self.func(obj)

    def setter(self, fset):
        self.fset = fset
        return self

    def __set__(self, obj, value):
        if not self.fset:
            raise AttributeError("can't set attribute")
        return self.fset(obj, value)

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

    def __init__(self):
        self.cache = dict()

    @my_property()
    def website_base_url(self):
        if 'website_base_url' in self.cache:
            return self.cache['website_base_url']
        else:
            return None
    
    @website_base_url.setter
    def website_base_url(self, value):
        self.cache['website_base_url'] = value
        return value
        
mySys = SysOptions()

# 输出None
print(mySys.website_base_url)

# 输出"http://www.jiangnangame.com"
mySys.website_base_url = 'http://www.jiangnangame.com'
print(mySys.website_base_url)

这个代码对于认真看了上篇文章的同学来说难度应该不大,因此这里我仅作简单的分析。

为了实现对属性编辑操作的拦截,我这里在描述器类my_property中定义了setter__set__这两个方法。

  • setter负责接收实际实现属性编辑操作的函数fset并将其保存到描述器对象中,此外还将可供调用的my_property实例对象再次返回回来,替换掉我们要实现拦截功能的类中原先的方法,实现函数的柯里化。

  • __set__作为描述器对象的方法,显然用于拦截针对描述器所属类的属性的编辑操作,并触发之前保存下来的fset函数进行编辑操作的处理。

这里需要说明的是,有的同学看到我在类SysOptions中似乎定义了两个名为website_base_url的方法,可能会感到疑惑:你这定义了两次,难道不会发生某些冲突吗?

首先必须澄清的是,就Python自身的语言特性而言,在class中重复定义属性或者方法,后者会自动覆盖前者,并不会引发任何报错,如下例所示:

class MyClass:
    name = 'Trump'
    name = 'Biden'
    
    def foo(self):
        print('hello world');
        
    def foo(self):
        print('Hello World');
        
obj = MyClass()
# 输出Biden
print(obj.name)
# 输出Hello World
obj.foo()

于是乎,当代码解析到用@website_base_url.setter装饰的第二个website_base_url方法时,实际上是会覆盖掉之前的定义的。但是没有任何关系,因为之前定义的website_base_url经过装饰器的包装已经变成了一个可供调用的my_property对象,而其setter方法在接收处理属性值编辑操作的函数后,仍然会将这个对象再次返回回来,因此最终挂在MyClass类上的website_base_url属性就是我们需要的这个作为描述器的my_property对象,这种写法是不会出任何问题的。

一个小改进

在前面的例子中,在修饰website_base_url方法时我们用的是依赖魔法方法__call__实现的@my_property()。虽然在前面的例子中没有体现,但聪明的同学或许已经猜到了,刻意设计这么一种写法是为了便于在实例化my_property对象时可以传入某些自定义参数,例如@my_property(year = 2024)。但有时候我们只想直接使用默认设置,并不想传参,这时候这对括号就显得多余了。

在下面这个编造的例子中,我进行了一些改进:

class my_property:

    def __init__(self, func = None, *arg, **kwargs):
        self.func = func
        self.year = 2023
        if 'year' in kwargs:
            self.year = kwargs['year']

    def __get__(self, obj, cls):
        print('year = ', self.year)
        return self.func(obj)

    # 省略set相关代码...

    def __call__(self, func, *args, **kwargs):
        if self.func is None:
            self.func = func
        return self
        
class SysOptions:

    def __init__(self):
        self.cache = dict()

    @my_property
    def website_base_url(self):
        return 'http://www.jiangnangame.com'
        
    @my_property(year = 2024)
    def website_name(self):
        return 'JiangNanGame'
        
mySys = SysOptions()

# 输出:
# year = 2023
# http://www.jiangnangame.com
print(mySys.website_base_url)

# 输出:
# year = 2024
# JiangNanGame
print(mySys.website_name)

在本例中,@my_property@my_property(year = 2024)的主要区别在于描述器对象挂载fset的时机不同。

对于前者而言,在Python解释器碰到@符号需要调用装饰器函数构造新函数(在本例中就是实例化my_property对象)的时候,我们要修饰的函数对应形参func传入描述器类的构造函数__init__并完成了挂载。

对于后者而言,首先my_property(year = 2024)主动实例化并返回了我们需要的my_property对象,同时将自定义的year参数传入__init__;此时描述器对象func由于没有传入任何对应的值,仍为None。等到Python解释器碰到@符号的时候,描述器对象的魔法方法__call__被触发,此时被修饰的函数才被传入该魔法方法并完成挂载。

Python原生实现的property修饰器

原本这部分内容我写到这里应该已经结束了,但我现在才发现Python已经原生实现了property修饰器的功能:

class SysOptions:

    def __init__(self):
        self.cache = dict()

    @property
    def website_base_url(self):
        if 'website_base_url' in self.cache:
            return self.cache['website_base_url']
        else:
            return None
    
    @website_base_url.setter
    def website_base_url(self, value):
        self.cache['website_base_url'] = value
        return value
        
mySys = SysOptions()

# 输出<class 'property'>
print(property)

# 输出None
print(mySys.website_base_url)

# 输出"http://www.jiangnangame.com"
mySys.website_base_url = 'http://www.jiangnangame.com'
print(mySys.website_base_url)

在Python内部,原生的property修饰器是利用C语言在底层实现的,感兴趣的同学可以自行去阅读其源码:github.com/python/cpyt…

必须指出的是,虽然Python提供了原生实现,但如前文展示的需要传入自定义参数或者更加复杂的情形,原生接口就无能为力了。因此我们还是需要掌握自定义的property修饰器的实现方法。

使用装饰器导致原始函数属性丢失问题

关于描述器对象和函数装饰器结合的话题讲完了。在文章的最后,让我们回到函数装饰器本身,来聊一聊另外一个比较重要的知识点。

在上回的文章中,我们已经知道Python中函数装饰器机制的本质,是对函数通过装饰器进行包装,并利用包装后的新函数替换掉原来的被包装函数。

那么细心的同学肯定注意到了,在包装过程中原函数的某些属性会丢失,比如函数的所属模块__module__、函数的名称__name__、嵌套函数和类中方法的嵌套结构__qualname__、函数注释__doc__,以及可能存在的程序员自行为原函数定义的自有属性。

这可能会导致原函数的功能出现异常,因此我们需要这些可能丢失的属性一并复制到包装后得到的新函数上。

幸运的是,Python原生提供了实现这个功能的函数functools.update_wrapper来解决这个我们编写装饰器的常见需求。

下面我直接把源代码(位于Python解释器安装目录/Lib/functools.py)贴出来,由于代码很简单,我这里就不作额外说明了。

# update_wrapper() and wraps() are tools to help write
# wrapper functions that can handle naive introspection

WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
                       '__annotations__')
WRAPPER_UPDATES = ('__dict__',)
def update_wrapper(wrapper,
                   wrapped,
                   assigned = WRAPPER_ASSIGNMENTS,
                   updated = WRAPPER_UPDATES):
    """Update a wrapper function to look like the wrapped function

       wrapper is the function to be updated
       wrapped is the original function
       assigned is a tuple naming the attributes assigned directly
       from the wrapped function to the wrapper function (defaults to
       functools.WRAPPER_ASSIGNMENTS)
       updated is a tuple naming the attributes of the wrapper that
       are updated with the corresponding attribute from the wrapped
       function (defaults to functools.WRAPPER_UPDATES)
    """
    for attr in assigned:
        try:
            value = getattr(wrapped, attr)
        except AttributeError:
            pass
        else:
            setattr(wrapper, attr, value)
    for attr in updated:
        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
    # Issue #17482: set __wrapped__ last so we don't inadvertently copy it
    # from the wrapped function when updating __dict__
    wrapper.__wrapped__ = wrapped
    # Return the wrapper so this can be used as a decorator via partial()
    return wrapper

下面通过一个简单的例子来说明该函数的效果:

import functools

def decorator(func):
    def wrapper():
        func()
    return wrapper

def decorator_fixed(func):
    def wrapper():
        func()
    # 把原函数的各种属性复制过去
    functools.update_wrapper(wrapper, func)
    return wrapper
    
@decorator
def foo1():
    print("I'm foo1")
    
@decorator_fixed
def foo2():
    print("I'm foo1")

# 输出wrapper,foo1的元数据丢失!
print(foo1.__name__)
# 正确输出foo2
print(foo2.__name__)