最近在阅读 mmdetection 源码时,发现了大量的装饰器的用法,而且发现当被装饰的类被导入时,装饰器的效果就会被自动触发。于是查阅了相关资料,梳理了一下装饰器的运行逻辑,并在文章最后解释一下mmdetection的注册机制。

前言

首先我们需要知道,python 中一切皆对象,函数也是,只不过函数实现了 __call__ 方法,使得它可以被调用 (callable)。
所以,函数既然是对象,我们不妨就把函数称为函数对象,函数对象可以作为参数传入另一个函数中。同样,函数也可以返回一个函数对象,比如下面的例子:

def calculate(func, a, b):  
    return func(a, b)  
  
def my_sum(a, b):  
    return a + b  
  
result = calculate(my_sum, 1, 2)  
  
print(result)  # 3

在上面的例子中,我们定义了一个 calcuelate,它接收三个参数,然后返回 func(a, b),所以 func 必须是 callable,也就是它得是函数,或者一个实现了 __call__ 的对象。

然后定义了一个简单的函数 my_sum,它接收两个参数,求和并返回。

最后通过 calculate(my_sum, 1, 2),把 my_sum,1,2 传入到 calculate 中,calculate 返回 my_sum(1, 2),my_sum(1, 2) 再返回求和结果 3。

一、函数的装饰器

装饰器是 python 的一种语法糖(帮助我们以少量的代码方便地实现某种功能)。
直接看下面的例子,可以看到在 def my_func(x): 上方有一个 @timeit 的字段:

import time  
  
  
def timeit(f):  
  
    def wrapper(x):  
        start = time.time()  
        ret = f(x)  
        print(time.time() - start)  
        return ret  
  
    return wrapper  
  
@timeit  
def my_func(x):  
    time.sleep(x)  
    return x  
  
  
x = my_func(1)  
  
print(f'x = {x}')

这个@timeit 的字段就是装饰器,我们目前看不懂它,但可以通过以下方式把他转化为我们看得懂的样子,
在这里插入图片描述

所以,上面的代码等效于下面的代码:

import time  
  
  
def timeit(f):  
  
    def wrapper(x):  
        start = time.time()  
        ret = f(x)  
        print(time.time() - start)  
        return ret  
  
    return wrapper  
  

def my_func(x):  
    time.sleep(x)  
    return x  

my_func = timeit(my_func)  # 等价于这个
  
x = my_func(1)  
  
print(f'x = {x}')

所以,装饰器的作用就是,把装饰器字段下方的函数名,作为参数传到装饰器字段所指的函数中,再把返回值保存回原来的函数名(还是看前面的图比较好理解,语言描述总是很绕…)。

那么这段代码运行后会发生什么呢?我们只需要把 my_func 传入 timeit 然后一步一步看就好了:
在这里插入图片描述

可以看出,wrapper 打印了 my_func(1) 执行所花费的时间,my_func(1) 的返回值作为 wrapper 的返回值被返回。

所以输出是这样的:

1.0006966590881348
x = 1

另外,结合 用*args和**kwargs来接收任意数量的参数 和 python 装饰器,我们可以计算任何函数所花费的时间了:

import time  
  
  
def timeit(f):  
  
    def wrapper(*args, **kwargs):  
        start = time.time()  
        ret = f(*args, **kwargs)  
        print(time.time() - start)  
        return ret  
  
    return wrapper  

# 把@timeit添加到函数前,当该函数被执行时,将打印执行花费的时间

参考链接:【python】装饰器超详细教学,用尽毕生所学给你解释清楚,以后再也不迷茫了!

二、类的装饰器

前面讲的装饰器涉及到两个函数 (function),也就是 @ 后的字段是一个函数的名字,其实 @ 后也可以跟一个类的名字,只要这个类实现了 __call__ 方法,因为实现了 __call__ 方法的类,可以被当作一个函数来调用,所以原理和前一节是一样的。

那么如果,装饰器装饰的是一个类呢?像下面这样:

@xxx
class my_class(object):
	...

通常用一个函数来修饰类是为了修改类的属性,所以这个函数通常以一个类作为参数,修改类的属性后再返回这个类,例如:

def modify(cls):  
    cls.species = cls.__name__  # .__name__将获得cls的名称
    return cls  
  
  
@modify  
class dog(object):  
    pass  

按照上一节的解释,上面的代码就等价于下面的代码:

def modify(cls):  
    cls.species = cls.__name__  # .__name__将获得cls的名称
    return cls  
  
    
class dog(object):  
    pass  

dog = modify(dog)  # 装饰器的效果等价于这一行

然后我们可以实例化出一个对象:

my_dog = dog()  # 等价于 my_dog = modify(dog)()
  
print(my_dog.species)

输出:

dog

三、装饰器的触发时机

另外,需要注意装饰器被触发的时机。

从前面两节的例子可以看出,无论装饰器装饰的是函数还是类,它们都等价于在函数/类下方,加上一句 函数名/类名 = 装饰器名(函数名/类名),只要这个被装饰的函数/类所在的文件被执行时,装饰器的效果就会被触发

如果我们把代码都写在一个文件里,也许不会注意到这个事情,因为在运行代码的时候,装饰器的效果就顺带执行了。

但是,在 python 中,当我们从一个文件中导入函数或类时,该文件也会被执行!所以从一个文件导入一个被装饰的函数/类时,装饰器也就会被触发了。而这往往容易被忽视。

3.1 当一个“被装饰的函数”被导入时

举个例子,新建一个 test.py,填入以下代码:

# test.py
def debug(func):  
    print(f'DEBUG {func.__name__}')  # .__name__将获得func的名称  
    return func  
  
@debug  
def say():  
    print("hello, this say")

上面的代码中,debug 装饰了 say 函数,其效果是在执行 say 函数前,会先打印 DEBUG say

然后在同级目录新建一个 main.py,并从 test.py 导入 say:

# main.py
from test import say

最后,直接运行这个只包含一行代码的 main.py 文件,可以发现有输出:

DEBUG say

这说明,装饰器已经产生效果了,因为test.py 完全可以换成以下内容,输出还是一样的,我们也就能够理解为什么导入时就会触发装饰器了:

def debug(func):  
    print(f'DEBUG {func.__name__}')  # .__name__将获得func的名称
    return func  
  
  
def say():  
    print("hello, this say")  
  
  
say = debug(say)  # 装饰器的效果等效于这一行,会导致输出 `DEBUG say`

因为当我们 from test import say,test.py 就会自动被执行,通常我们会把模块中的代码放在 if __name__ == '__main__' 下面来避免被其他文件导入时而执行,但从上面的代码可以看出,装饰器产生的效果是无法放到 if __name__ == '__main__' 下的,所以上面就会导致输出 DEBUG say 了。

3.2 当一个“被装饰的类”被导入时(mmdetection)

被装饰的类也是同理的。不举其他例子了,直接用 mmdetection 作为示例。

mmdetection 中的注册机制就是利用装饰器实现的。我写了一个简化版的代码用于解释其逻辑,代码层级结构是这样的:

mmdetection/
├── my_module
│   ├── __init__.py
│   ├── registry.py
│   └── datasets.py
└── main.py

__init__.py

from .datasets import CocoDataset

registry.py

class Registry(object):  
  
    def __init__(self, name):  
        self._name = name  # 初始化_name为name
        self._module_dict = dict()  # 初始化_module_dict 为空的字典
  
    def __repr__(self):  
        format_str = f'items={list(self._module_dict.keys())}'  # 格式化打印 _module_dict字典中的所有key
        return format_str  
  
    def register_module(self, cls):  
        self._module_dict[cls.__name__] = True  # 增加一条字典元素, key是cls的名称,value是布尔值True
        return cls  # 返回 cls
  
  
DATASETS = Registry('dataset')

datasets.py

from my_module.registry import DATASETS  
  
  
@DATASETS.register_module  
class CocoDataset(object):  
    def __init__(self):  
        self.name = 'coco'

main.py

from my_module.registry import DATASETS
print(DATASETS)

当我们运行 main.py 会发现输出是:

items=['CocoDataset']

我们梳理一下代码的运行逻辑,首先当我们运行 main.py 时,首先执行 from my_module.registry import DATASETS,也就是从 my_module 这个包 (package) 中的 registry 模块中导入 DATASETS。

而根据导入机制,在真正导入 DATASETS 前,会先执行 my_module 包下的 __init__ 文件(参看python init.py的讲解),也就是这句代码被运行了:

from .datasets import CocoDataset

我们前往 datasets.py 查看发现 CocoDataset 是一个被 @DATASETS.register_module 装饰的类,因为前面说过导入一个被装饰的函数/类时,装饰器就会被触发,也就是等价于执行这个操作:

DATASETS.register_module(CocoDataset)

这会发生什么呢?查看一下 registry.py,不难理解 DATASETS 的成员 _module_dict(字典类型),会增加一条元素:key = CocoDataset,value = True。

至此,main.py 的第一行代码执行完毕,然后 print(DATASETS)

items=['CocoDataset']

打印出 _module_dict 所包含的 key 值,也就是 ‘CocoDataset’。

所以当我们使用 mmdetection 时,要多关注一下装饰器修饰了哪些东西,以及再导入包时多查看一下 __init__ 文件的内容,就不难理解所谓的“注册”了。

四、总结

  1. 装饰器的效果,等效于去掉装饰器的字段后,在函数和类的定义下面加上:函数名/类名 = 装饰器名(函数名/类名)
  2. 因为装饰器的效果等效于一句可执行的代码,所以注意文件被执行的时机,特别是导入文件中的函数/类时,文件就会被执行一次,这一点很容易被忽视。
  3. mmdetection 利用了装饰器机制,实现了在导入一些类时,自动将类的名称(以及一些其他信息)保存到另一个类的成员中,并把这个过程称为“注册”。
Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐