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