'intelligently' 在依赖项更改时重置 Python 中的记忆 属性 值的最佳方法

Best way to 'intelligently' reset memoized property values in Python when dependencies change

我正在编写一个 class,其中包含我只想在必要时计算的各种属性(惰性求值)。但是,更重要的是,我想确保如果它们的计算所依赖的任何属性发生更改,则不会返回 'stale' 值。除了实现某种计算图(有没有办法做到这一点?)之外,我想不出任何好的方法来做到这一点,这涉及到很多 setter 方法和手工编码的重置相关计算值。

是否有 easier/better 或更不容易出错的方法来做到这一点? (我正在处理的实际应用程序比这个更复杂,计算图更大)

from math import pi

class Cylinder:

    def __init__(self, radius, length, density):

        self._radius = radius
        self._length = length
        self._density = density
        self._volume = None
        self._mass = None

    @property
    def volume(self):
        if self._volume is None:
            self._volume = self.length*pi*self.radius**2
            print("Volume calculated")
        return self._volume

    @property
    def mass(self):
        if self._mass is None:
            self._mass = self.volume*self.density
            print("Mass calculated")
        return self._mass

    @property
    def length(self):
        return self._length

    @length.setter
    def length(self, value):
        self._length = value
        self._volume = None
        self._mass = None
        print("Volume and mass reset")

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        self._radius = value
        self._volume = None
        self._mass = None
        print("Volume and mass reset")

    @property
    def density(self):
        return self._density

    @density.setter
    def density(self, value):
        self._density = value
        self._mass = None
        print("Mass reset")

(打印语句暂时仅供解释)

这行得通。在解释器中:

>>> c = Cylinder(0.25, 1.0, 450)
>>> c.radius
0.25
>>> c.length
1.0
>>> c.density
450
>>> c.volume
Volume calculated
0.19634954084936207
>>> c.mass
Mass calculated
88.35729338221293
>>> c.length = c.length*2  # This should change things!
Volume and mass reset
>>> c.mass
Volume calculated
Mass calculated
176.71458676442586
>>> c.volume
0.39269908169872414
>>> 

我能找到的最接近的答案是 this one,但我认为这是针对记忆函数结果而不是属性值。

这是一个解决方案:

from math import pi

class Cylinder:
    _independent = {"length", "radius", "density"}
    _dependent = {"volume", "mass"}

    def __init__(self, radius, length, density):
        self._radius = radius
        self._length = length
        self._density = density
        self._volume = None
        self._mass = None

    def __setattr__(self, name, value):
        if name in self._independent:
            name = f"_{name}"
            for var in self._dependent:
                super().__setattr__(f"_{var}", None)
        if name in self._dependent:
            print("Cannot set dependent variable!")
            return
        super().__setattr__(name, value)

    @property
    def volume(self):
        if self._volume is None:
            self._volume = self.length*pi*self.radius**2
            print("Volume calculated")
        return self._volume

    @property
    def mass(self):
        if self._mass is None:
            self._mass = self.volume*self.density
            print("Mass calculated")
        return self._mass

    @property
    def length(self):
        return self._length

    @property
    def radius(self):
        return self._radius

    @property
    def density(self):
        return self._density

想法是使用__setattr__委托所有集合操作。

这是一个描述符,可用于作为其他属性函数的属性。它应该只在它依赖的变量发生变化时才重新计算。

from weakref import WeakKeyDictionary

class DependantAttribute:
    """Describes an attribute that is a fuction of other attributes.

    Only recalculates if one of the values it relies on changes. 
    'interns' the value and the values used to calculate it.
    This attribute must be set in the class's __init__

    name - the name of this instance attribute
    func - the function used to calculate the value
    attributes - instance attribute names that this attribute relies on
                 must match function parameter names
    mapping - not implemented: {attribute_name: function_parameter_name}

    """
    def __init__(self, name, func, attributes):
        self.name = name
        self.func = func
        self.attributes = attributes
        #self.mapping = None
        self.data = WeakKeyDictionary()

    def __get__(self, instance, owner):
        values = self.data.get(instance)
        if any(getattr(instance,attr) != values[attr]
               for attr in self.attributes):
            value = self.recalculate(instance)
            setattr(instance,self.name, value) 
        return self.data.get(instance)['value']

    def __set__(self, instance, value):
        # store the new value and current attribute values
        values = {attr:getattr(instance,attr) for attr in self.attributes}
        # validate?! : value == self.recalculate(**values)
        values['value'] = value
        self.data[instance] = values

    def recalculate(self, instance):
            # calculating a new value relies on
            # attribute_name == function_parameter_name
            kwargs = {attr:getattr(instance,attr) for attr in self.attributes}
            return self.func(**kwargs)

这依赖于实例属性名称与函数的参数名称相同。虽然我没有在这里实现它,但可能有一个字典将实例属性名称映射到函数参数名称以解决任何不匹配问题。

虽然在 __get__ 方法中重新计算和设置似乎有点 奇怪 ,但我暂时保留它。


要使用描述符将其实例化为class属性;传递其名称、要使用的函数以及它所依赖的实例属性的名称。

from math import pi
# define the functions outside the class
def volfnc(length, radius):
    return length * pi * pow(radius,2)
def massfnc(volume, density):
    return volume * density

class Cylinder:
    volume = DependantAttribute('volume',volfnc, ('length','radius'))
    mass = DependantAttribute('mass',massfnc, ('volume','density'))

    def __init__(self, radius, length, density):

        self.radius = radius
        self.length = length
        self.density = density

        # the dependent attributes must be set in __init__
        self.volume = volfnc(length,radius)
        self.mass = massfnc(self.volume,density)


c = Cylinder(1,1,1)
d = Cylinder(1,2,1)

>>> c.volume, c.mass
(3.141592653589793, 3.141592653589793)
>>> d.volume, d.mass
(6.283185307179586, 12.566370614359172)
>>> c.radius = 2
>>> d.density = 3
>>> c.volume, c.mass
(12.566370614359172, 12.566370614359172)
>>> d.volume, d.mass
(6.283185307179586, 18.84955592153876)

这里是 的扩展版本,它将依赖图实现为字典,以确定哪些因变量需要重置。感谢@Sraw 为我指明了这个方向。

from itertools import chain
from math import pi

class Cylinder:

    _dependencies = {
        "length": ["volume"],
        "radius": ["volume"],
        "volume": ["mass"],
        "density": ["mass"]
    }
    _dependent_vars = set(chain(*list(_dependencies.values())))

    def __init__(self, radius, length, density):
        self._radius = radius
        self._length = length
        self._density = density
        self._volume = None
        self._mass = None

    def _reset_dependent_vars(self, name):
        for var in self._dependencies[name]:
            super().__setattr__(f"_{var}", None)
            if var in self._dependencies:
                self._reset_dependent_vars(var)

    def __setattr__(self, name, value):
        if name in self._dependent_vars:
            raise AttributeError("Cannot set this value.")
        if name in self._dependencies:
            self._reset_dependent_vars(name)
            name = f"_{name}"
        super().__setattr__(name, value)

    @property
    def volume(self):
        if self._volume is None:
            self._volume = self.length*pi*self.radius**2
            print("Volume calculated")
        return self._volume

    @property
    def mass(self):
        if self._mass is None:
            self._mass = self.volume*self.density
            print("Mass calculated")
        return self._mass

    @property
    def length(self):
        return self._length

    @property
    def radius(self):
        return self._radius

    @property
    def density(self):
        return self._density

这是另一个有趣的解决方案,使用我发现的名为 pythonflow 的包。它确实使构建计算图变得容易,但我不清楚它是否进行惰性求值。据我所知,它不存储或缓存值,您只能临时更改常量。如果我对这个包有更多了解,我会更新这个答案...

>>> import pythonflow as pf
>>> import math
>>> with pf.Graph() as graph:
...     pi = pf.constant(math.pi)
...     length = pf.constant(1.0)
...     radius = pf.constant(0.25)
...     density = pf.constant(450)
...     volume = length*pi*radius**2
...     mass = volume*density
... 
>>> graph(volume)
0.19634954084936207
>>> graph(mass)
88.35729338221293
>>> graph(volume, {length: graph(length)*2})
0.39269908169872414
>>> graph(mass, {length: graph(length)*2})
176.71458676442586
>>>