本节书摘来自华章出版社《Python编程实战:运用设计模式、并发和程序库创建高质量程序》一 书中的第2章,第2.4节,作者:(美) Mark Summerfield,更多章节内容可以访问云栖社区“华章计算机”公众号查看。
2.4 修饰器模式
一般来说,“修饰器”(decorator)是个单参数的函数,其参数也是函数,修饰器返回的新函数与经由参数传入的原函数名称相同,但功能更强。框架(例如web框架)经常通过修饰器把用户所编写的函数集成进来。
由于修饰器模式非常有用,所以Python提供了原生支持。在Python语言中,函数与方法都可以用修饰器来修饰。此外,还有“类修饰器”(class decorator),它也是个单参数的函数,其参数是个类,由这种修饰器所返回的新类的名称与原类相同,但功能更多。有时可以通过类修饰器来实现继承。
由上一节的composite及price属性可知,Python内置的property()函数能当作修饰器来用。此外,Python标准库也内置了一些修饰器。比方说,在实现了__eq__()及__lt__()
特殊方法(这两个方法分别规定了“==”
及“<”
这两个比较操作符的含义)的类上面,可以运用@functools.total_ordering
类修饰器。修饰后的类会包含其他几个与比较操作有关的特殊方法,从而能支持全套的比较操作符(也就是<、<=、==、!=、=>、>)。
由于修饰器只能接受一个参数(这个参数表示待修饰的函数、方法或类),所以从理论上来讲,无法“参数化”(parameterize)。但实际上没有这个限制,稍后我们会创建“参数化的修饰器工厂”(parameterized decorator factory),这种工厂可以返回“修饰器函数”(decorator function),而修饰器函数又可以用来修饰函数、方法或类。
2.4.1 函数修饰器与方法修饰器
所有的函数修饰器与方法修饰器的大体结构都相同。首先,创建“包装函数”(wrapper function,本书总是将该函数命名为wrapper()),然后在包装函数里调用原函数。调用前可执行“预处理”(preprocessing),获取到结果后,还可以执行“后加工”(postprocessing)。包装函数的返回值也很灵活:可以把原函数的调用结果直接返回,也可以先修改再返回,还可以返回其他值。最后,修饰器把包装函数作为调用结果返回,返回后的函数会以原函数的名义将其取代。
修饰器以“@”符号开头,其缩进级别与受修饰的函数、方法、类的def或class语句相同,@符号后面是修饰器的名称。多个修饰器可以叠放,也就是说,修饰过的函数还可以继续修饰,如图2.6所示。稍后我们会举例说明。
上面这段代码用@float_args_and_return修饰器(马上就会列出其代码)来修饰mean()函数。未修饰的mean()函数接受两个或多个数值作参数,并返回float型的平均值。而修饰后的mean()函数(由于修饰后的函数取代了原函数,所以二者同名)则可以接受两个或多个任意类型的参数,只要这些参数都能转换为float就行。若是不修饰,那么调用mean(5, "6", "7.5")时会抛出TypeError,因为int与str不能直接相加。但修饰之后就没有这个问题了,因为"6"和"7.5"这两个字符串可通过float("6")与float("7.5")转换成有效的数值。
其实修饰器就是一种“语法糖”(syntactic sugar)。刚才那段代码也可以写成:
上面这段代码先创建了未受修饰的mean()函数,然后手工调用修饰器,用修饰后的版本替换原来的mean()。虽说修饰器用起来很方便,但有时候还是必须像上面这样自己来调用。本节最后就有个例子,在ensure()函数里调用Python内置的@property修饰器。2.2节的has_methods()函数也曾直接调用Python内置的@classmethod修饰器。
float_args_and_return()函数是函数修饰器,所以只接受一个函数作其唯一的参数。为便于处理,可以把args与kwargs当成包装函数的参数,这样写实际上就等于令包装函数可以接受任意参数。(args与kwargs的含义请参阅1.2节中的补充知识。)原函数(也就是包裹在wrapper()里的function()函数)也许对参数有一些限制,而包装函数应该把收到的参数全都传递给原函数。
本例中,我们在包装函数里把传给原函数的若干个“位置参数”(positional argument)转换成一份float列表,然后用*args里的值(这些值可能和原函数接收到的参数不同)来调用原函数,并把原函数的返回值转换成float,用作包装函数的返回值。
创建好包装函数之后,函数修饰器会将其返回。
但不巧的是,如果采用刚才的写法,那么修饰后的函数的__name__属性就和原函数不同了(变成了“wrapper”),而且即便原函数有docstring,修饰后的函数也不会再有了。所以,这种替换方式并不完美。为解决此问题,Python标准库提供了@functools.wraps修饰器,我们可以在修饰器里用它来修饰包装函数,以确保修饰后的函数的__name__与__doc__属性分别与原函数的名称及docstring相符。
上面是修改之后的修饰器。它用@functools.wraps修饰器来保证wrapper()函数的__name__属性与传入的函数相符(本例中是"mean"),并确保wrapper()函数的docstring与原函数相同(在本例中docstring为空)。在编写修饰器时,最好总是用@functools.wraps来修饰wrapper()函数,这样就能在“回溯信息”(trackback)里打印出受修饰的原函数名了(而不是包装函数的名称"wrapper"),并且还能使用户访问到原函数的docstring。
用来修饰make_tagged()与repeat()函数的statically_typed()函数是个修饰器工厂,也就是一种能制作修饰器的函数。由于它不像修饰器那样只接受一个函数、方法或类作为其惟一参数,所以本身并不能算作修饰器。我们想用一套模板来制造修饰器,以指定受修饰的函数能够接受何种类型的位置参数(还可以指定返回值的类型)。为此,我们创建了statically_typed()函数,其参数表示受修饰的函数应该具备何种类型的位置参数,此外还有个可选的关键字参数,用于指定受修饰函数的返回值类型,statically_typed()函数会根据这些参数来创建适当的修饰器。
Python遇到@statically_typed(...)这种代码时,会用给定的参数调用statically_typed()函数,并用其返回的函数做修饰器来修饰@statically_typed(...)后面的那个函数(在本例中,修饰make_tagged()与repeat()函数)。
我们可以遵照一套固定的流程来创建修饰器工厂。首先,创建修饰器函数,在该函数内创建包装函数,包装函数的编写方式如前所述。在包装函数尾部,把调用原函数所得的返回值(也可以修改返回值,或用其他值来替换)返回给上一层。在修饰器函数尾部返回包装函数。最后,在修饰器工厂函数尾部返回修饰器。
上面这段代码首先创建了名为decorator()的修饰器函数,然而函数名称在此处无关紧要。在修饰器函数里,我们用老办法创建了包装函数。不过,本例的包装函数实现起来相当复杂,因为在调用原函数之前,它要检查用户传入的所有位置参数,判断其个数与类型是否符合要求;如果指定了返回值类型,那么在调用完原函数后,还要判断返回值是否符合要求。包装函数在完成上述判断之后,会返回原函数的执行结果。
创建好包装函数之后,修饰器会将其返回,而statically_typed()函数又会在其末尾将修饰器返回。Python碰到@statically_typed(str, int, str)这种源代码之后,会调用statically_typed()函数。这个函数会返回它所创建的decorator()函数,而decorator()函数已经把用户传给statically_type()函数的参数捕获了。现在回到“@”这里,Python看到“@”后,会执行decorator()函数,并把@statically_typed(str, int, str)后面的那个函数当成参数传给decorator()。用作参数的这个函数既可以是开发者用def语句定义的,也可以是由其他修饰器所返回的。在本例中,这个函数是repeat(),它是decorator()函数唯一的参数。decorator()函数用捕捉到的状态(也就是用户传给statically_typed()函数的参数)来创建新的wrapper()函数,并将其返回。Python用这个wrapper()替换掉原来的repeat()函数。
请注意,调用statically_typed()函数时,它所创建的decorator()函数会返回wrapper(),而这个wrapper()函数捕获了其外围函数的状态,尤其是types元组和return_type关键字参数。像这样能够捕获状态的函数或方法就叫做“闭包”(closure)。正是由于Python支持闭包,所以我们才能够创建出“参数化的工厂函数”、修饰器及修饰器工厂。
从静态类型语言(比如C、C++、Java)转入Python的开发者可能比较喜欢用修饰器对函数的参数及返回值执行静态类型检查,但这样做会增加Python程序在运行期的开销,而编译型语言则没有这种运行期开销。此外,尽管上面这个例子确实能展示Python的灵活性,但在动态类型语言里检测参数及返回值类型本身就是一种不太符合Python风格的做法(如果真需要在编译期执行静态类型检查,那么可以使用5.2节所说的Cython)。下一节将要讲到的参数验证可能比参数类型检查更为有用。
修饰器的写法需要逐渐适应,但总体来说还是比较简单的。如果想创建“无参数化的”(unparameterized)函数修饰器或方法修饰器,那么就创建修饰器函数,然后在函数里创建并返回包装函数即可。前面讲到的@float_args_and_return修饰器与接下来要讲的@Web.ensure_logged_in修饰器都是如此。若想创建参数化的修饰器,则先要创建修饰器工厂,由工厂来创建修饰器,再由修饰器来创建包装函数,statically_typed()函数就是这样创建的。
上述代码片段节选自一个管理“邮件列表”(mailing list)的web应用程序,该程序使用了名为bottle的轻量级web框架(网址:bottlepy.org)。此框架提供了@application.post修饰器,可以把函数与URL相关联。对于本例来说,只有当用户登录之后,我们才允许其访问mailinglists/add页面,若未登录,则将其重定向至login页面。按照传统写法,在每一个产生网页的函数里,都需要使用相同的代码来判断用户是否已经登录,而创建了@Web.ensure_logged_in这个修饰器之后,就可以把此事交由修饰器处理,这样的话,与登录有关的那部分代码就不会和函数混在一起了。
当用户登录网站时,login页面的后台程序会验证用户名与密码,若二者相符,则在浏览器中设置cookie,此cookie的生命期在本次会话完结时终止。
如果与用户所请求之页面相关联的函数受到@ensure_logged_in修饰器保护(比如与mailinglists/add页面相关联的person_add_submit()函数),那么就会执行由修饰器所定义的wrapper()函数。这个包装函数首先尝试从cookie中获取“用户名”(username)。若无法获取,则说明用户尚未登录,这时我们会将用户重定向到web应用程序的login页面。若能获取到,则说明用户已经登录,我们将username添加到关键字参数里,然后调用原函数并返回其结果。这样做的好处是,开发者在编写原函数时,可以假定用户已经登录了,于是原函数就能直接使用username,而无须再担心安全问题了。
2.4.2 类修饰器
我们经常要创建具有很多“可读写”属性的类,这样的类一般会有大量重复或相似的getter与setter。比方说,要创建Book类,用以保存书的名称、ISBN、价格及数量。我们需要四个@property修饰器,其代码都差不多(例如:@property def title(self): return title)。此外,还需四个setter方法,每个方法都要验证用户所传入的参数,然而验证价格与验证数量所用的代码会很相似,只是最小值与最大值不同而已。假如要建立许多这样的类,那么就会出现大量重复的代码。
幸好Python提供了类修饰器,可以消除这种重复代码。比方说,在2.2节那个例子中,我们通过类修饰器来给自己所创建的类提供接口检查功能,这样就不用每次都编写十行重复代码了。此处再举一例:我们用类修饰器来实现Book类,使其具备四个经过充分验证的属性,外加一个推算而来的只读属性。
因为self.title、self.isbn等全都是属性,所以__init__()方法里的四个赋值操作均由相关的“属性设置器”(property setter)验证过了。然而我们无须手工创建这些属性,也无须手工编写其getter与setter代码,只需运用四次类修饰器,即可获得所需的全部功能。
ensure()函数接受两个参数,一个是属性名,另一个是“验证器函数”(validator function),然后返回一个类修饰器。这个类修饰器会运用在@ensure之后的类上面。
上述代码首先创建未受修饰的Book类,然后调用一次ensure()函数(以便创建quantity属性),用此函数所返回的类修饰器来修饰Book。修饰过的Book类多了个名叫quantity的属性。接下来,再次调用ensure()函数(以便创建price属性),用此函数所返回的类修饰器继续修饰Book,该类经过二次修饰之后,便多了quantity与price属性。把这个过程再重复两遍,Book类的四个属性就齐全了。
整个修饰过程看起来是逆向的,似乎和语句书写顺序相反。下面列出与该过程等效的伪代码:
运用修饰器之前,class Book语句必须先执行,因为首次调用ensure()函数时(首次调用是为了给Book新增quantity属性)要以这个类对象为参数,而调用之后所返回的类对象还要用作上一层ensure()函数的参数,依此类推。
请注意,price与quantity属性用的是同一个验证器函数,区别仅在于参数不同。实际上,is_in_range()函数是个工厂函数,它会生成新的is_in_range()函数,在新函数中,传给原函数的最小值与最大值参数都会以硬编码的形式嵌入其中。
我们稍后就能看到,由ensure()函数所返回的类修饰器会向Book类中添加相关的属性,而属性的setter方法又会调用与该属性相对应的验证器函数,并向验证器传入两个参数,一个是属性名,另一个是用户想要设置的新属性值。若新值有效,则验证器函数照常返回,若无效,则抛出异常(比如ValueError)。在讲解ensure()的实现代码之前,我们先来看两个验证器。
上面这个验证器用来确保Book的title属性不是空字符串。由ValueError的用法可以看出,把属性名放在错误消息里对调试工作是很有帮助的。
上面这个函数是个工厂函数,每次调用时,都会创建新的验证器函数,用以确保给定的参数是个数字(相关的检测语句用到了名为numbers.Number的抽象基类),而且其值位于适当范围内。创建好验证器之后,工厂函数就会将其返回。
ensure()函数会以给定的属性名、验证器函数及可选的docstring为参数来创建类修饰器,而调用ensure()就相当于用它所创建的类修饰器来修饰特定的类,修饰后的类会有新的属性。
decorator()函数只有一个参数,这个参数是类对象。此函数首先创建privateName变量,其属性值存放在与该变量同名的attribute里。(在本例中,Book类的self.title属性将存放在名为self.__title的私有attribute里面。)接下来,创建getter函数,用以返回保存在私有attribute中的属性值。Python内置的getattr()函数接受两个参数,一个是对象,另一个是attribute名,它返回对象中的attribute值,如果没有找到这个attribute,那么就抛出AttributeError。创建完getter后,又创建了setter函数,此函数调用捕获到的validate()函数,在validate()函数没有抛出异常的情况下,把私有的attribute修改成新值。Python内置的setattr()函数有三个参数,分别是对象、attribute名以及新的attribute值,此函数会把相应的attribute设置成新值,若原来没有叫这个名字的attribute,则会新建一个。
编写完getter及setter后,decorator()用这两个函数新建了一个属性,并通过内置的setattr()函数把该属性作为attribute添加到用户所传入的类里面,这个attribute的名称与公开的属性名一致。创建属性时所用的property()函数是由Python内置的,它有四个可选参数,分别是getter、setter、deleter与docstring,此函数返回创建好的属性。前面我们还曾把这个函数当成“方法修饰器”(method decorator)来用。decorator()函数最后会把修改好的类返回给ensure(),而ensure()这个“类修饰器工厂函数”(class decorator factory function)又会把decorator()函数返回给调用者。
1.?用类修饰器新增属性
在上面那个例子中,每一个需要验证的attribute都必须用@ensure类修饰器来描述。有些Python程序员不喜欢把多个类修饰器迭加在一起,他们会把相关的attribute都放在类里面,然后只用一个类修饰器来修饰,这样写出来的代码更易读懂。
上面是改写后的Book类,我们用@do_ensure类修饰器与Ensure实例来实现与前例相同的功能。每次构造Ensure对象时,都要传入验证函数,而@do_ensure类修饰器会用带有验证机制的同名属性来替换相应的Ensure实例。本例所用的验证函数(比如is_non_empty_str()等)与早前范例所用的相同。
上面这个类很简单,它用来保存验证器函数,相关属性的setter函数稍后会用到这个验证器。另外,在构建Ensure实例时,还可以指定属性的docstring。比方说,Book类的title属性一开始是个Ensure实例,但创建好Book类之后,@do_ensure修饰器就会把每个Ensure实例都替换成对应的属性。所以,在修饰之后的Book类中,名为title的这个attribute就不再是Ensure实例了,而变成了title属性(该属性的setter函数会用到原来Ensure实例中的验证函数)。
上面这个类修饰器可分为三部分。在第一部分里,我们定义了名为make_property()的“嵌套函数”(nested function)。该函数有两个参数,一个是属性名(比如"title"),另一个是Ensure类型的attribute,此函数将返回一个属性,该属性会把其值保存在私有的attribute中(比如title属性的值就保存在名为__title的attribute中)。属性的setter函数还会调用原来Ensure实例的验证器函数。在第二部分里,我们遍历类中的每一个attribute,并用新的属性来替换原先的Ensure实例。第三部分会把修改后的类返回。
执行完修饰器后,受修饰的类里原有的Ensure型attribute都会被同名且带有验证机制的属性所取代。
从理论上说,可以不写那个叫做make_property()的嵌套函数,而是把其中的代码都放在if isinstance()这行测试语句下面。但实际上,由于“后期绑定”(late binding)机制的某些问题,我们没办法这么做,所以必须把相关代码单独放在一个函数里。在创建修饰器或修饰器工厂时,此问题时有发生,不过这些状况大都可以通过单独编写一个函数来解决(这个函数有可能像本例一样是个嵌套函数)。
2.?用类修饰器实现继承
有时我们创建基类只是为了使子类能够继承其中的某些方法或数据。当要创建的子类个数比较多时,这种设计方式就显得很灵活了,因为每个子类无须重复实现基类的方法或数据。然而若是继承下来的那些方法或数据在子类里都无须改动,那么可以改用类修饰器来达到此目的。
比方说,在3.5节中,我们将编写名为Mediated的基类,其中提供了self.mediator数据属性以及on_change()方法。Button与Text均继承自该类,二者都会用到基类的数据及方法,但却不修改它们。
上面这个基类节选自mediator1.py。Button及Text子类都使用通常的写法来继承它(也就是class Button(Mediated): ...和class Text(Mediated): ...)。但由于子类无须修改继承下来的on_change()方法,所以我们也可以改用类修饰器来实现继承。
上面这段代码节录自mediator1d.py。类修饰器的用法与早前范例相同:即分别用
@mediated class Button: ...及@mediated class Text: ...来修饰Button及Text类。修饰后的类的行为与通过继承而来的类相同。
函数修饰器与类修饰器都是Python中易用且强大的功能。我们刚才已经看到,类修饰器有时可以实现继承。而创建修饰器就是一种简单的“元编程”(metaprogramming)形式,类修饰器通常可用来取代更为复杂的元编程技术(比如“元类”,metaclass)。