如何让functools.lru_cache返回新实例?

11

我在一个返回可变对象的函数上使用了Python的lru_cache,像这样:

import functools

@functools.lru_cache()
def f():
    x = [0, 1, 2]  # Stand-in for some long computation
    return x

如果我调用这个函数,改变了结果并再次调用它,我就无法获得一个“新的”,未更改的对象:
a = f()
a.append(3)
b = f()
print(a)  # [0, 1, 2, 3]
print(b)  # [0, 1, 2, 3]

我明白为什么会出现这种情况,但这不是我想要的。解决方法是让调用者负责使用list.copy

a = f().copy()
a.append(3)
b = f().copy()
print(a)  # [0, 1, 2, 3]
print(b)  # [0, 1, 2]

然而,我希望在f内部解决这个问题。一个简洁的解决方案可能是:
@functools.lru_cache(copy=True)
def f():
    ...

虽然 functools.lru_cache 实际上不需要 copy 参数。

你有什么建议可以更好地实现这个行为吗?

编辑

基于 holdenweb 的回答,这是我的最终实现。默认情况下,它的行为与内置的 functools.lru_cache 完全相同,并在提供 copy=True 时扩展它的复制行为。

import functools
from copy import deepcopy

def lru_cache(maxsize=128, typed=False, copy=False):
    if not copy:
        return functools.lru_cache(maxsize, typed)
    def decorator(f):
        cached_func = functools.lru_cache(maxsize, typed)(f)
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            return deepcopy(cached_func(*args, **kwargs))
        return wrapper
    return decorator

# Tests below

@lru_cache()
def f():
    x = [0, 1, 2]  # Stand-in for some long computation
    return x

a = f()
a.append(3)
b = f()
print(a)  # [0, 1, 2, 3]
print(b)  # [0, 1, 2, 3]

@lru_cache(copy=True)
def f():
    x = [0, 1, 2]  # Stand-in for some long computation
    return x

a = f()
a.append(3)
b = f()
print(a)  # [0, 1, 2, 3]
print(b)  # [0, 1, 2]

我认为这不是functools.lru_cache的适当使用方式,因为您的函数没有任何参数(用于在缓存中查找先前的结果)。要使您的功能正常工作,请返回x的副本(或deepcopy) - 这可能会破坏在此场景中使用装饰器的目的。 - martineau
1
@martineau 在我的实际使用情况中,该函数将具有参数。这对问题并不重要。 - jmd_dk
嗯,就这个问题而言,它确实会使得提供一个可行的答案变得更加容易,但我希望我最终能够给你提供一些有用的东西——如果你必须要使用的话,尽管有文档警告。 - holdenweb
我使用 lru_cache 作为 functools.cache 的替代方案(记忆化,需要 py>=3.9)。它很好用,除了 Python 没有常量性(不像 C++,它是所有值语义而不是对象语义),所以缓存的对象可以被用户修改。这很麻烦。因此,在缓存对象上创建一个深拷贝是一个非常好的主意。 - dvorak4tzx
1个回答

7
由于lru_cache装饰器的行为对您来说不太合适,因此您最好建立自己的装饰器,返回从lru_cache得到的对象的一个副本。这意味着,第一次使用特定参数调用将创建两个对象的副本,因为现在缓存将只保存原型对象。
这个问题变得更加困难,因为lru_cache可以接受参数(mazsizetyped),所以对lru_cache调用返回一个装饰器。记住,装饰器以函数作为其参数并(通常)返回一个函数,你需要替换lru_cache,使用一个带有两个参数的函数代替它,并返回一个函数,该函数以函数作为参数,并返回一个(包装的)函数,这是一个不容易理解的结构。
然后,您将使用copying_lru_cache装饰器编写您的函数,而不是标准的装饰器,现在在更新的装饰器内手动应用。
根据突变的严重程度,您可能可以不使用深度复制,但您没有提供足够的信息来确定。因此,您的代码将会是这样的:
from functools import lru_cache
from copy import deepcopy

def copying_lru_cache(maxsize=10, typed=False):
    def decorator(f):
        cached_func = lru_cache(maxsize=maxsize, typed=typed)(f)
        def wrapper(*args, **kwargs):
            return deepcopy(cached_func(*args, **kwargs))
        return wrapper
    return decorator

@copying_lru_cache()
def f(arg):
    print(f"Called with {arg}")
    x = [0, 1, arg]  # Stand-in for some long computation
    return x

print(f(1), f(2), f(3), f(1))

这段代码的含义是打印输出。
Called with 1
Called with 2
Called with 3
[0, 1, 1] [0, 1, 2] [0, 1, 3] [0, 1, 1]

因此,你需要的缓存行为似乎已经存在。请注意,lru_cache 的文档特别警告说:

通常情况下,只有当您想要重用先前计算过的值时,才应使用LRU缓存。因此,对于具有副作用的函数、需要在每次调用时创建不同可变对象的函数或纯度不高的函数(例如time()或random()),缓存是没有意义的。


1
@martineau 嗯,每个计算可能需要很长时间,即使返回的对象很小。 - jmd_dk
@holdenweb,您能否提供一个使用您的copying_lru_cache装饰器的小而完整的示例? - jmd_dk
@jmd_dk:这是一个装饰器——像其他装饰器一样使用它即可。 - martineau
@jmd_dk:在函数的 def f(): 前面放置 @copying_lru_cache 应该可以解决问题(除非我漏掉了什么)。我会让你和作者协商解决。 - martineau
整个回答都没有经过深思熟虑,如果我不能纠正它(现在已经快到睡觉时间了),那我应该删除它。 - holdenweb
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接