I have classes that have some data, and whenever this data changes, it should notify observers of that data change. There also should be a method to add new observers.
My first approach was to use a property with a custom setter. This means that whenever the =
operator was used on the data it would call the custom setter, which would notify the observers.
Like so:
# (1)
from typing import Callable
class Foo:
def __init__(self):
self.__bar = 0
self.__bar_observers = []
@property
def bar(self):
return self.__bar
@bar.setter
def bar(self, value):
self.__bar = value
#notify observers
for obs in self.__bar_observers:
obs(value)
def add_bar_observer(self, observer : Callable[[int], None]):
self.__bar_observers.append(observer)
f = Foo()
f.add_bar_observer(lambda newval : print(f'new bar val {newval}'))
f.bar = 8
But this is a lot of boilerplate and is many lines of code just for one property. If Foo
had 10 observable properties instead of just one, this class would be quite large.
It would be good if this pattern could be created automatically with a decorator.
Here is my attempt at that:
# (2)
def observable_property(initial_value):
class _observable_property:
def __init__(self, fget):
pass
# We don't know if __set__, __get__, or add_observer will be called first, so we need to call this at the start of all 3.
def __init(self, obj):
#On first time called, create the data
if not hasattr(obj, f'__{self.name}'):
setattr(obj, f'__{self.name}', initial_value)
if not hasattr(obj, f'__{self.name}_observers'):
setattr(obj, f'__{self.name}_observers', [])
def __get__(self, obj, objtype=None):
self.__init(obj)
return getattr(obj, f'__{self.name}')
def __set__(self, obj, value):
self.__init(obj)
setattr(obj, f'__{self.name}', value)
#Notify all of the observers
for obs in getattr(obj, f'__{self.name}_observers'):
obs(value)
def __set_name__(self, owner: type, name : str):
self.name = name
#Add a function to the owner to add new observers
def add_observer(obj, observer):
self.__init(obj)
getattr(obj, f'__{name}_observers').append(observer)
setattr(owner, f'add_{name}_observer', add_observer)
return _observable_property
And then a test:
# (3)
Meters = NewType('Meters', float)
Kelvin = NewType('Kelvin', float)
class Axis:
@observable_property(initial_value=Meters(0.0))
def position(self):
pass
@observable_property(initial_value=Kelvin(0.0))
def temperature(self):
pass
a = Axis()
a.add_position_observer(lambda newval : print(f'New position for a: {newval}'))
a.add_temperature_observer(lambda newval : print(f'New temperature for a: {newval}'))
a.position = Meters(5.0)
a.temperature = Kelvin(300.0)
This works, but two downsides I can see are that the add_{}_observer
functions do not appear in an IDE IntelliSense autocomplete, and it also loses its type hints.
What do you think? Would you use this in production code?
I would also prefer it if the initial_value
was set in the constructor of the class, instead of as a decorator parameter, and then the function to be decorated returns that, as is the case with the standard @property
decorator.
i.e
# (4)
class Axis:
def __init__(self):
self.__position=Meters(0.0)
@observable_property
def position(self):
return self.__position
But I cannot do this as I can't mutate the self.__position
from the __set__
descriptor. Is there any way to get the style of # (4)
, with the functionality of # (3)
?
Thanks.