functools.cached_property(Python 3.8)
/ / / 阅读数:14320前言
缓存属性 (cached_property
) 是一个非常常用的功能,很多知名 Python 项目都自己实现过它。我举几个例子:
bottle.cached_property
Bottle 是我最早接触的 Web 框架,也是我第一次阅读的开源项目源码。最早知道cached_property
就是通过这个项目,如果你是一个 Web 开发,我不建议你用这个框架,但是源码量少,值得一读~
werkzeug.utils.cached_property
Werkzeug 是 Flask 的依赖,是应用cached_property
最成功的一个项目。代码见延伸阅读链接 2
pip._vendor.distlib.util.cached_property
PIP 是 Python 官方包管理工具。代码见延伸阅读链接 3
kombu.utils.objects.cached_property
Kombu 是 Celery 的依赖。代码见延伸阅读链接 4
django.utils.functional.cached_property
Django 是知名 Web 框架,你肯定听过。代码见延伸阅读链接 5
甚至有专门的一个包: pydanny/cached-property ,延伸阅读 6
如果你犯过他们的代码其实大同小异,在我的观点里面这种轮子是完全没有必要的。Python 3.8 给functools
模块添加了cached_property
类,这样就有了官方的实现了
PS: 其实这个 Issue 2014 年就建立了,5 年才被 Merge!
Python 3.8 的 cached_property
借着这个小章节我们了解下怎么使用以及它的作用(其实看名字你可能已经猜出来):
❯ ./python.exe Python 3.8.0a4+ (heads/master:9ee2c264c3, May 28 2019, 17:44:24) [Clang 10.0.0 (clang-1000.11.45.5)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> from functools import cached_property >>> class Foo: ... @cached_property ... def bar(self): ... print('calculate somethings') ... return 42 ... >>> f = Foo() >>> f.bar calculate somethings 42 >>> f.bar 42 |
上面的例子中首先获得了 Foo 的实例 f,第一次获得f.bar
时可以看到执行了 bar 方法的逻辑 (因为执行了 print 语句),之后再获得f.bar
的值并不会在执行 bar 方法,而是用了缓存的属性的值。
标准库中的版本还有一种的特点,就是加了线程锁,防止多个线程一起修改缓存。通过对比 Werkzeug 里的实现帮助大家理解一下:
import time from threading import Thread from werkzeug.utils import cached_property class Foo: def __init__(self): self.count = 0 @cached_property def bar(self): time.sleep(1) # 模仿耗时的逻辑,让多线程启动后能执行一会而不是直接结束 self.count += 1 return self.count threads = [] f = Foo() for x in range(10): t = Thread(target=lambda: f.bar) t.start() threads.append(t) for t in threads: t.join() |
这个例子中,bar 方法对self.count
做了自增 1 的操作,然后返回。但是注意 f.bar 的访问是在 10 个线程下进行的,里面大家猜现在f.bar
的值是多少?
❯ ipython -i threaded_cached_property.py Python 3.7.1 (default, Dec 13 2018, 22:28:16) Type 'copyright', 'credits' or 'license' for more information IPython 7.5.0 -- An enhanced Interactive Python. Type '?' for help. In [1]: f.bar Out[1]: 10 |
结果是 10。也就是 10 个线程同时访问f.bar
,每个线程中访问时由于都还没有缓存,就会给f.count
做自增 1 操作。第三方库对于这个问题可以不关注,只要你确保在项目中不出现多线程并发访问场景即可。但是对于标准库来说,需要考虑的更周全。我们把cached_property
改成从标准库导入,感受下:
❯ ./python.exe Python 3.8.0a4+ (heads/master:8cd5165ba0, May 27 2019, 22:28:15) [Clang 10.0.0 (clang-1000.11.45.5)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> import time >>> from threading import Thread >>> from functools import cached_property >>> >>> >>> class Foo: ... def __init__(self): ... self.count = 0 ... @cached_property ... def bar(self): ... time.sleep(1) ... self.count += 1 ... return self.count ... >>> >>> threads = [] >>> f = Foo() >>> >>> for x in range(10): ... t = Thread(target=lambda: f.bar) ... t.start() ... threads.append(t) ... >>> for t in threads: ... t.join() ... >>> f.bar 1 |
可以看到,由于加了线程锁,f.bar
的结果是正确的 1。
cached_property 不支持异步
除了 pydanny/cached-property 这个包以外,其他的包都不支持异步函数:
❯ ./python.exe -m asyncio asyncio REPL 3.8.0a4+ (heads/master:8cd5165ba0, May 27 2019, 22:28:15) [Clang 10.0.0 (clang-1000.11.45.5)] on darwin Use "await" directly instead of "asyncio.run()". Type "help", "copyright", "credits" or "license" for more information. >>> import asyncio >>> from functools import cached_property >>> >>> >>> class Foo: ... def __init__(self): ... self.count = 0 ... @cached_property ... async def bar(self): ... await asyncio.sleep(1) ... self.count += 1 ... return self.count ... >>> f = Foo() >>> await f.bar 1 >>> await f.bar Traceback (most recent call last): File "/Users/dongwm/cpython/Lib/concurrent/futures/_base.py", line 439, in result return self.__get_result() File "/Users/dongwm/cpython/Lib/concurrent/futures/_base.py", line 388, in __get_result raise self._exception File "<console>", line 1, in <module> RuntimeError: cannot reuse already awaited coroutine |
pydanny/cached-property 的异步支持实现的很巧妙,我把这部分逻辑抽出来:
try: import asyncio except (ImportError, SyntaxError): asyncio = None class cached_property: def __get__(self, obj, cls): ... if asyncio and asyncio.iscoroutinefunction(self.func): return self._wrap_in_coroutine(obj) ... def _wrap_in_coroutine(self, obj): @asyncio.coroutine def wrapper(): future = asyncio.ensure_future(self.func(obj)) obj.__dict__[self.func.__name__] = future return future return wrapper() |
我解析一下这段代码:
- 对
import asyncio
的异常处理主要为了处理 Python 2 和 Python3.4 之前没有 asyncio 的问题 __get__
里面会判断方法是不是协程函数,如果是会return self._wrap_in_coroutine (obj)
_wrap_in_coroutine
里面首先会把方法封装成一个 Task,并把 Task 对象缓存在obj.__dict__
里,wrapper 通过装饰器asyncio.coroutine
包装最后返回。
为了方便理解,在 IPython 运行一下:
In : f = Foo() In : f.bar # 由于用了`asyncio.coroutine`装饰器,这是一个生成器对象 Out: <generator object cached_property._wrap_in_coroutine.<locals>.wrapper at 0x10a26f0c0> In : await f.bar # 第一次获得f.bar的值,会sleep 1秒然后返回结果 Out: 1 In : f.__dict__['bar'] # 这样就把Task对象缓存到了f.__dict__里面了,Task状态是finished Out: <Task finished coro=<Foo.bar() done, defined at <ipython-input-54-7f5df0e2b4e7>:4> result=1> In : f.bar # f.bar已经是一个task了 Out: <Task finished coro=<Foo.bar() done, defined at <ipython-input-54-7f5df0e2b4e7>:4> result=1> In : await f.bar # 相当于 await task Out: 1 |
可以看到多次 await 都可以获得正常结果。如果一个 Task 对象已经是 finished 状态,直接返回结果而不会重复执行了。
延伸阅读
- https://github.com/bottlepy/bottle/blob/master/bottle.py#L233
- https://github.com/pallets/werkzeug/blob/9394af646038abf8b59d6f866a1ea5189f6d46b8/src/werkzeug/utils.py#L53
- https://github.com/pypa/pip/blob/873662179aebbf5eacdf681078f47bbfe5ee6149/src/pip/_vendor/distlib/util.py#L437
- https://github.com/celery/kombu/blob/080502fd5c4736c0063daa08f5bbd672c3975a68/kombu/utils/objects.py#L5
- https://github.com/django/django/blob/master/django/utils/functional.py#L7
- https://github.com/pydanny/cached-property
- https://github.com/python/cpython/pull/6982
我觉得,延伸阅读的链接可以在正文中加几个超连接。