第 6 章 内置模块(上)
第 42 条:用 functools.wraps 定义函数修饰器
Python 用特殊的语法来表示修饰器(decorator),这些修饰器可以用来修饰函数。对于受到封装的原函数来说,修饰器能够在那个函数执行之前以及执行完毕之后,分别运行一些附加代码。这使得开发者可以在修饰器里面访问并修改原函数的参数及返回值,以实现约束语义(enforce semantics)、调试程序、注册函数等目标。
例如,要打印某个函数在受到调用时所接收的参数以及该函数的返回值。对于包含一系列函数调用的递归函数来说,这样的调试功能尤其有用。下面就来定义这种修饰器:
def trace(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print('%s(%r, %r) -> %r' % (func.__name__, args, kwargs, result))
return result
return wrapper
可以用 @ 符号把刚才那个修饰器套用到某个函数上面。
@trace
def fibonacci(n):
"""Return the n-th Fibonacci number"""
if n in (0, 1):
return n
return (fibonacci(n - 2) + fibonacci(n - 1))
使用 @ 符号来修饰函数,其效果就等于先以该函数为参数,调用修饰器,然后把修饰器所返回的结果,赋给同一个作用域中与原函数同名的那个变量。
fibonacci = trace(fibonacci)
如果调用这个修饰之后的 fibonacci 函数,那么它就会在执行原函数之前及之后,分别运行 wrapper 中的附加代码,使开发者能够看到原 fibonacci 函数在递归栈的每一个层级上所具备的参数和返回值。
fibonacci(3)
>>>
fibonacci((1,), 0) -> 1
fibonacci((0,), 0) -> 0
fibonacci((1,), 0) -> 1
fibonacci((2,), 0) -> 1
fibonacci((3,), 0) -> 2
上面这个修饰器虽然可以正常运作,但却会产生一种不希望看到的副作用。也就是说,修饰器所返回的那个值,其名称会和原来的函数不同,它现在不叫 fibonacci 了。
print(fibonacci)
>>>
<function trace.<locals>.wrapper at Ox107f7ed08>
这种效果的产生原因比较微妙。trace 函数所返回的值,是它内部定义的那个 wrapper。而又使用 trace 来修饰原有的 fibonacci 函数,于是,Python 就会把修饰器内部的那个 wrapper 函数,赋给当前模块中与原函数同名的 fibonacci 变量。对于调试器和对象序列化器等需要使用内省(introspection)机制的那些工具来说,这样的行为会干扰它们的正常运作。
例如,修饰后的 fibonacci 函数,会令内置的 help 函数失效。
help(fibonacci)
>>>
Help on function wrapper in module __main__:
wrapper(*args, **kwargs)
这个问题,可以用内置的 functools 模块中名为 wraps 的辅助函数来解决。wraps 本身也是修饰器,它可以帮助开发者编写其他修饰器。将 wraps 修饰器运用到 wrapper 函数之后,它就会把与内部函数相关的重要元数据全部复制到外围函数。
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
# ...
return wrapper
@trace
def fibonacci(n):
# ...
现在,即使函数经过修饰,运行 help 也依然能打印出合理的结果。
help(fibonacci)
>>>
Help on function fibonacci in module __main__:
fibonacci(n)
Return the n-th Fibonacci number
刚才看到:如果编写修饰器的时候,没有用 wraps 做相应的处理,那就会令 help 函数失效。除了 help 函数,修饰器还会导致其他一些难于排查的问题。为了维护函数的接口,修饰之后的函数,必须保留原函数的某些标准 Python 属性,例如,__name__
和 __module__
。因此,需要用 wraps 来确保修饰后的函数具备正确的行为。
总结
- Python 为修饰器提供了专门的语法,它使得程序在运行的时候,能够用一个函数来修改另一个函数。
- 对于调试器这种依靠内省机制的工具,直接编写修饰器会引发奇怪的行为。
- 内置的 functools 模块提供了名为 wraps 的修饰器,开发者在定义自己的修饰器时,应该用 wraps 对其做一些处理,以避免一些问题。
第 43 条:考虑以 contextlib 和 with 语句来改写可复用的 try/finally 代码
有些代码,需要运行在特殊的情境之下,开发者可以用 Python 语言的 with 语句来表达这些代码的运行时机。例如,如果把互斥锁放在 with 语句之中,那就表示:只有当程序持有该锁的时候,with 语句块里的那些代码,才会得到运行。
lock = Lock()
with lock:
print('Lock is held')
由于 Lock 类对 with 语句提供了适当的支持,所以上面那种写法,可以达到与 try/finally 结构相仿的效果。
lock.acquire()
try:
print('Lock is held')
finally:
lock.release()
在上面两种写法中,使用 with 语句的那个版本更好一些,因为它免去了编写 try/finally 结构所需的重复代码。开发者可以用内置的 contextlib 模块来处理自己所编写的对象和函数,使它们能够支持 with 语句。该模块提供了名为 contextmanager 的修饰器。一个简单的函数,只需经过 contextmanager 修饰,即可用在 with 语句之中。这样做,要比标准的写法更加便捷。如果按标准方式来做,那就要定义新的类,并提供名为 __enter__
和 __exit__
的特殊方法。
例如,当程序运行到某一部分时,希望针对这部分代码,打印出更为详细的调试信息。下面定义的这个函数,可以打印两种严重程度(severity level)不同的信息:
def my_function():
logging.debug('Some debug data')
logging.error('Error log here')
logging.debug('More debug data')
如果程序的默认信息级别是 WARNING(警告),那么运行该函数时,就只会打印出 ERROR(错误)级别的消息。
my_function()
>>>
Error log here
可以定义一种情境管理器,来临时提升该函数的信息级别(log level,日志级别)。下面这个辅助函数,会在运行 with 块内的代码之前,临时提升信息级别,待 with 块执行完毕,再恢复原有级别。
@contextmanager
def debug_logging(level):
logger = logging.getLogger()
old_level = logger.getEffectiveLevel()
logger.setLevel(level)
try:
yield
finally:
logger.setLevel(old_level)
yield 表达式所在的地方,就是 with 块中的语句所要展开执行的地方。with 块所抛出任何异常,都会由 yield 表达式重新抛出,这使得开发者可以在辅助函数里面捕获它。
现在重新运行这个 my_function 函数,但是这一次,把它放在 debug_logging 情境之下。大家可以看到: with 块中的那个 my_function 函数,会把所有 DEBUG(调试)级别的信息打印出来,而 with 块外的那个 my_function 函数,则不会打印 DEBUG 级别的信息。
with debug_logging(logging.DEBUG):
print('Inside:')
my _function()
print('After:')
my_function()
>>>
Inside:
Some debug data
Error log here
More debug data
After:
Error log here
使用带有目标的 with 语句
传给 with 语句的那个情境管理器,本身也可以返回一个对象。而开发者可以通过 with 复合语句中的 as 关键字,来指定一个局部变量,Python 会把那个对象,赋给这个局部变量。这使得 with 块中的代码,可以直接与外部情境相交互。
例如,要向文件中写入数据,并且要确保该文件总是能正确地关闭。这个功能,可以通过 with 语句实现。把 open 传给 with 语句,并通过 as 关键字来指定一个目标变量,用以接收 open 所返回的文件句柄,等到 with 语句块退出时,该句柄会自动关闭。
with open('/tmp/my_output.txt', 'w') as handle:
handle.write('This is some data!')
与每次手工开启并关闭文件句柄的写法相比,上面这个写法更好一些。它使得开发者能够确信:只要程序离开 with 语句块,文件就一定会关闭。此外,它也促使开发者在打开文件句柄之后,尽量少写一些代码,这通常是一种良好的编程习惯。
只需在情境管理器里,通过 yield 语句返回一个值,即可令自己的函数把该值提供给由 as 关键字所指定的目标变量。例如,下面定义的这个情境管理器,能够获取 Logger 实例、设置其级别,并通过 yield 语句将其提供给由 as 关键字所指定的目标:
@contextmanager
def log_level(level, name):
logger = logging.getLogger(name)
old_level = logger.getEffectiveLevel()
logger.setLevel(level)
try:
yield logger
finally:
logger.setLevel(old_level)
由于 with 语句块可以把严重级别调低,所以在 as 目标变量上面调用 debug 等方法时,可以打印出 DEBUG 级别的调试信息。与之相反,若直接在 logging 模块上面调用 debug,则不会打印出任何 DEBUG 级别的消息,因为 Python 自带的那个 logger,默认会处在 WARNING 级别。
with log_level(logging.DEBUG, 'my-log') as logger:
logger.debug('This is my message!')
logging.debug('This will not print')
>>>
This is my message!
退出 with 语句块之后,在名为’my-log’ 的 Logger 上面调用 debug 方法,是打印不出消息的,因为该 Logger 的严重级别,已经恢复到默认的 WARNING 了。但是,由于 ERROR 级别高于 WARNING 级别,所以在这个 Logger 上面调用 error 方法,仍然能够打印出消息。
logger = logging.getLogger('my-log')
logger.debug('Debug will not print')
logger.error('Error will print')
>>>
Error will print
总结
- 可以用 with 语句来改写 try/finally 块中的逻辑,以便提升复用程度,并使代码更加整洁。
- 内置的 contextlib 模块提供了名叫 contextmanager 的修饰器,开发者只需用它来修饰自己的函数,即可令该函数支持 with 语句。
- 情境管理器可以通过 yield 语句向 with 语句返回一个值,此值会赋给由 as 关键字所指定的变量。该机制阐明了这个特殊情境的编写动机,并令 with 块中的语句能够直接访问这个目标变量。
第 44 条:用 copyreg 实现可靠的 pickle 操作
内置的 pickle 模块能够将 Python 对象序列化为字节流,也能把这些字节反序列化为 Python 对象。经过 pickle 处理的字节流,不应该在未受信任的程序之间传播。pickle 的设计目标是提供一种二进制渠道,使开发者能够在自己所控制的各程序之间传递 Python 对象。
例如,要用 Python 对象表示玩家的游戏进度。下面这个 GameState 类,包含了玩家当前的级别,以及剩余的生命数。
class GameState(object):
def __init__(self):
self.level = 0
self.lives = 4
程序会在游戏过程中修改 GameState 对象。
state = GameState()
state.level += 1 # Player beat a level
state.lives -= 1 # PTayer had to try again
玩家退出游戏时,程序可以把游戏状态保存到文件里,以便稍后恢复。使用 pickle 模块来实现这个功能,是非常简单的。下面这段代码,会把 GameState 对象直接写到一份文件里:
state_path = '/tmp/game_state.bin'
with open(state_path, 'wb') as f:
pickle.dump(state, f)
以后,可以用 load 函数来加载这个文件,并把 GameState 对象还原回来。还原好的 GameState 对象,与没有经过序列化操作的普通对象一样,看不出太大区别。
with open(state_path, 'rb') as f:
state_after = pickle.load(f)
print(state_after.__dict__)
>>>
{'lives': 3, 'level': 1}
在游戏功能逐渐扩展的过程中,上面那种写法会暴露出一些问题。例如,为了鼓励玩家追求高分,想给游戏添加计分功能。于是,给 GameState 类添加 points 字段,以表示玩家的分数。
class GameState(object):
def __init__(self):
# .. .
self.points = 0
针对新版的 GameState 类来使用 pickle 模块,其效果与早前相同。下面这段代码,先用 dumps 函数将对象序列化为字符串,然后又用 loads 方法将字符串还原成对象,这样做,与通过文件来保存并还原对象,是相似的:
state = GameState()
serialized = pickle.dumps(state)
state_after = pickle.loads(serialized)
print(state_after.__dict__)
>>>
{'lives': 4, 'level': 0, 'points': 0}
但是,如果有一份存档,是用旧版的 GameState 格式保存的,而现在玩家又要用这份存档来继续游戏,那会出现什么情况呢?下面这段范例代码,根据新版的 GameState 定义,来对旧版的游戏存档进行 unpickle 操作:
with open(state_path, 'rb') as f:
state_after = pickle.load(f)
print(state_after.__dict__)
>>>
{'lives': 3, 'level': 1}
还原出来的对象,竟然没有 points 属性!由 pickle.load 所返回的对象,是个新版的 GameState 实例,可是这个新版的实例,怎么会没有 points 属性呢?这非常令人困惑。
assert isinstance(state_after, GameState)
这种行为,是 pickle 模块的工作机制所表现出的副作用。pickle 模块的主要意图,是帮助开发者轻松地在对象上面执行序列化操作。如果对 pickle 模块的运用超越了这个范围,那么该模块的功能就出现奇怪的问题。
要想解决这些问题,也非常简单,只需借助内置的 copyreg 模块即可。开发者可以用 copyreg 模块注册一些函数,Python 对象的序列化,将由这些函数来责对。这使得我们可以控制 pickle 操作的行为,令其变得更加可靠。
1.为缺失的属性提供默认值
在最简单的情况下,可以用带默认值的构造器,来确保 GameState 对象在经过 unpickle 操作之后,总是能够具备所有的属性。下面重新来定义 GameState 类的构造器:
class GameState(object):
def __init__(self, level=0, lives=4, points=0):
self.level = level
self.lives = lives
self.points = points
为了使用这个构造器进行 pickle 操作,定义了下面这个辅助函数,它接受 GameState 对象,并将其转换为一个包含参数的元组,以便提供给 copyreg 模块。返回的这个元组,含有 unpickle 操作所使用的函数,以及要传给那个 unpickle 函数的参数。
def pickle_game_state(game_state):
kwargs = game_state.__dict__
return unpickle_game_state, (kwargs,)
现在,定义 unpickle_game_state 这个辅助函数。该函数接受由 pickle_game_state 所传来的序列化数据及参数,并返回响应的 GameState 对象。这其实就是对构造器做了小小的封装。
def unpickle_game_state(kwargs):
return GameState(**kwargs)
下面通过内置的 copyreg 模块来注册 pickle_game_state 函数。
copyreg.pickle(GameState, pickle_game_state)
序列化与反序列化操作,都可以像原来那样,照常进行。
state = GameState()
state.points += 1000
serialized = pickle.dumps(state)
state_after = pickle.loads(serialized)
print(state_after.__dict__)
>>>
{'lives': 4, 'level' : 0, 'points': 1000}
注册好 pickle_game_state 函数之后,可以修改 GameState 的定义,给玩家一定数量的魔法卷轴。这次修改与早前给 GameState 类添加 points 字段时所做的修改是类似的。
class GameState(object):
def __init__(self, level=0, lives=4, points=0, magic=5):
# ...
但是这一次,对旧版的 GameState 对象进行反序列化操作,就可以得到正确的游戏数据了,而不会再像原来那样,丢失某些属性。由于 unpickle_game_state 会直接调用 GameState 构造器,所以反序列化之后的 GameState 对象,其属性是完备的。构造器的关键字参数,都带有默认值,如果某个参数缺失,那么对应的属性就会自动具备相应的默认值。于是,旧版的游戏存档在经过反序列化操作之后,就会拥有新的 magic 字段,而该字段的值,正是构造器的关键字参数所提供的默认值。
state_after = pickle.loads(serialized)
print(state_after.__dict__)
>>>
{'level': 0, 'points': 1000, 'magic': 5, 'lives': 4}
2.用版本号来管理类
有的时候,要从现有的 Python 类中移除某些字段,而这种操作,会导致新类无法与旧类相兼容。刚才那种通过带有默认值的参数来进行反序列化的方案,无法应对这种情况。
例如,现在认为,游戏不应该限制玩家的生命数量,于是,就想把生命数量这一概念,从游戏中拿掉。下面定义的这个 GameState 构造器,不再包含 lives 字段:
class GameState(object):
def __init__(self, level=0, points=0, magic=5):
# ...
修改了构造器之后,程序就无法对旧版的游戏数据进行反序列化操作了。因为旧版游戏数据中的所有字段,都会通过 unpickle_game_state 函数,传给 GameState 构造器,即使某个字段已经从新的 GameState 类里移除,它也依然要传入 GameState 构造器。
pickle.loads(serialized)
>>>
TypeError: __init__() got an unexpected keyword argument 'lives'
解决办法是:修改向 copyreg 模块注册的那个 pickle_game_state 函数,在该函数里添加一个表示版本号的参数。在对新版的 GameState 对象进行 pickle 的时候,pickle_game_state 函数会在序列化后的新版数据里面,添加值为 2 的 version 参数。
def pickle_game_state(game_state):
kwargs = game_state.__dict__
kwargs['version'] = 2
return unpickle_game_state, (kwargs,)
而旧版的游戏数据里面,并没有这个 version 参数,所以,在把参数传给 GameState 构造器的时候,我们可以根据数据中是否包含 version 参数,来做相应的处理。
def unpickle_game_state(kwargs):
version = kwargs.pop('version', 1)
if version == 1:
kwargs.pop('lives')
return GameState(**kwargs)
现在,就可以照常对旧版的游戏存档进行反序列化操作了。
copyreg.pickle(GameState, pickle_game_state)
state_after = pickle.loads(serialized)
print(state_after.__dict__)
>>>
{'magic': 5, 'level' : 0, 'points': 1000}
以后如果还要在这个类上面继续做修改,依然可以用这套办法来管理不同的版本。把旧版的类适配到新版的类时,需要编写一些代码,而这些代码,都可以放到 unpickle_game_statc 函数里面。
3.固定的引入路径
使用 pickle 模块时,还会出现一个问题,那就是:当类的名称改变之后,原有的数据无法正常执行反序列化操作。在程序的生命期内,通常会重构自己的代码,修改某些类的名称,并把它们移动到其他模块。在做这种重构时,必须多加小心,否则会令程序无法正常使用 picklc 模块。
下面把 GameState 类的名称改为 BetterGameState,并把原来的类从程序中彻底删掉:
class BetterGameState(object):
def __init__(self, level=0, points=0, magic=5):
# ...
如果对旧版的 GameState 对象进行反序列化操作,那么程序就会出错,因为它找不到这个类。
pickle.loads(serialized)
>>>
AttributeError: Can't get attribute 'GameState' on <module'__main__' from 'my _code.py '>
发生这个异常的原因在于:序列化之后的数据,把该对象所属类的引入路径,直接写在了里面。
print(serialized[:25])
>>>
b 'x80\x03c__main__\nGameState\nq\x00)'
这个问题也可以通过 copyreg 模块来解决。可以给函数指定一个固定的标识符,令它采用这个标识符来对数据进行 unpickle 操作。这使得在反序列化的时候,能够把原来的数据迁移到名称不同的其他类上面。可以利用这种间接的机制,来处理类名变更问题。
copyreg.pickle(BetterGameState, pickle_game_state)
使用了 copyreg 之后,可以看到:嵌入序列化数据之中的引入路径,不再指向 BetterGameState 这个类名了,而是会指向 unpickle_game_state 函数。
state = BetterGameState()
serialized = pickle.dumps(state)
print(serialized[:35])
>>>
b '\×80\x03c__main__\nunpickle_game_state\nq\x00}'
唯一要注意的地方是,不能修改 unpickle_game_state 函数所在的模块路径。程序把这个函数写进了序列化之后的数据里面,所以,将来执行反序列化操作的时候,程序也必须能找到这个函数才行。
总结
- 内置的 pickle 模块,只适合用来在彼此信任的程序之间,对相关对象执行序列化和反序列化操作。
- 如果用法比较复杂,那么 pickle 模块的功能也许就会出问题。
- 可以把内置的 copyreg 模块同 pickle 结合起来使用,以便为旧数据添加缺失的属性值、进行类的版本管理,并给序列化之后的数据提供固定的引入路径。