Fortunately, a dependency management system like this is easy enough to implement - if you're familiar with descriptors and metaclasses.
Our implementation needs 4 things:
- A new type of
property that knows which other properties depend on it. When this property's value changes, it will notify all properties that depend on it that they have to re-calculate their value. We'll call this class DependencyProperty. - Another type of
DependencyProperty that caches the value computed by its getter function. We'll call this DependentProperty. - A metaclass
DependencyMeta that connects all the DependentProperties to the correct DependencyProperties. - A function decorator
@cached_dependent_property that turns a getter function into a DependentProperty.
This is the implementation:
_sentinel = object() class DependencyProperty(property): """ A property that invalidates its dependencies' values when its value changes """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.dependent_properties = set() def __set__(self, instance, value): # if the value stayed the same, do nothing try: if self.__get__(instance) is value: return except AttributeError: pass # set the new value super().__set__(instance, value) # invalidate all dependencies' values for prop in self.dependent_properties: prop.cached_value = _sentinel @classmethod def new_for_name(cls, name): name = '_{}'.format(name) def getter(instance, owner=None): return getattr(instance, name) def setter(instance, value): setattr(instance, name, value) return cls(getter, setter) class DependentProperty(DependencyProperty): """ A property whose getter function depends on the values of other properties and caches the value computed by the (expensive) getter function. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.cached_value = _sentinel def __get__(self, instance, owner=None): if self.cached_value is _sentinel: self.cached_value = super().__get__(instance, owner) return self.cached_value def cached_dependent_property(*dependencies): """ Method decorator that creates a DependentProperty """ def deco(func): prop = DependentProperty(func) # we'll temporarily store the names of the dependencies. # The metaclass will fix this later. prop.dependent_properties = dependencies return prop return deco class DependencyMeta(type): def __new__(mcls, *args, **kwargs): cls = super().__new__(mcls, *args, **kwargs) # first, find all dependencies. At this point, we only know their names. dependency_map = {} dependencies = set() for attr_name, attr in vars(cls).items(): if isinstance(attr, DependencyProperty): dependency_map[attr] = attr.dependent_properties dependencies.update(attr.dependent_properties) attr.dependent_properties = set() # now convert all of them to DependencyProperties, if they aren't for prop_name in dependencies: prop = getattr(cls, prop_name, None) if not isinstance(prop, DependencyProperty): if prop is None: # it's not even a property, just a normal instance attribute prop = DependencyProperty.new_for_name(prop_name) else: # it's a normal property prop = DependencyProperty(prop.fget, prop.fset, prop.fdel) setattr(cls, prop_name, prop) # finally, inject the property objects into each other's dependent_properties attribute for prop, dependency_names in dependency_map.items(): for dependency_name in dependency_names: dependency = getattr(cls, dependency_name) dependency.dependent_properties.add(prop) return cls
And finally, some proof that it actually works:
class A(metaclass=DependencyMeta): def __init__(self, b, c): self.b = b self.c = c @property def b(self): return self._b @b.setter def b(self, value): self._b = value + 10 @cached_dependent_property('b', 'c') def a(self): print('doing expensive calculations') return self.b + 2*self.c obj = A(1, 4) print('b = {}, c = {}'.format(obj.b, obj.c)) print('a =', obj.a) print('a =', obj.a) # this shouldn't print "doing expensive calculations" obj.b = 0 print('b = {}, c = {}'.format(obj.b, obj.c)) print('a =', obj.a) # this should print "doing expensive calculations"