Search code examples
pythonpropertieslazy-evaluationmemoizationgetattribute

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


I'm writing a class with various attributes that I only want to calculate when necessary (lazy evaluation). However, more importantly, I want to make sure that 'stale' values are not returned if any of the attributes that their calculation depended on changed. Other than implementing some kind of computation graph (is there a way to do that?) I can't think of any good way to do it other than this which involves a lot of setter methods with hand-coded resetting of relevant calculated values.

Is there an easier/better or less error-prone way to do this? (The real application I am working on is more complicated than this with a larger computation graph)

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")

(Print statements are temporary for interpretation only)

This works. In interpreter:

>>> 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
>>> 

The closest answer I could find was this one but I think this is for memoized function results not attribute values.


Solution

  • Here is an extended version of @Sraw's answer which implements a dependency graph as a dictionary to figure out which dependent variables need to be reset. Credit to @Sraw for pointing me in this direction.

    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