I'd use composition rather than inheritance, and implement the MutableMapping
from collections.abc
so that I get some of the methods implemented for free. Per the documentation, you have to provide implementations of __getitem__
, __setitem__
, __delitem__
, __iter__
, and __len__
:
from collections.abc import MutableMapping
class BoundDict(MutableMapping):
"""Dict-like class that calls a callback on changes.
Note that the callback is invoked *after* the
underlying dictionary has been mutated.
"""
def __init__(self, callback, *args, **kwargs):
self._callback = callback
self._map = dict(*args, **kwargs)
def __getitem__(self, key):
return self._map[key]
def __setitem__(self, key, value):
self._map[key] = value
self._callback()
def __delitem__(self, key):
del self._map[key]
self._callback()
def __iter__(self):
return iter(self._map)
def __len__(self):
return len(self._map)
Note that you don't need to put a bare return
at the end of a method, and I've added a docstring to explain what the class does.
Thanks to the abstract base class, the following additional methods will now be implemented for you: __contains__
, keys
, items
, values
, get
, __eq__
, and __ne__
, pop
, popitem
, clear
, update
, and setdefault
. Because they all call through to the five fundamental methods defined above, it's guaranteed that any change through the MutableMapping
interface (although not changes to _map
directly) will invoke the callback, as it will always involve calling either __setitem__
or __delitem__
.
In use:
>>> bd = BoundDict(lambda: print('changed'), [('foo', 'bar')], hello='world')
>>> bd
<BoundDict object at 0x7f8a4ea61048>
>>> list(bd.items())
[('foo', 'bar'), ('hello', 'world')]
>>> bd['foo']
'bar'
>>> bd['hello']
'world'
>>> bd['foo'] = 'baz'
changed
>>> del bd['hello']
changed
>>> bd['foo']
'baz'
>>> bd['hello']
Traceback (most recent call last):
File "python", line 1, in <module>
File "python", line 16, in __getitem__
KeyError: 'hello'
The only downside to this is that if you have explicit type checking, you might have issues:
>>> isinstance(bd, dict)
False
However, you should generally be using the ABCs for your type checks, too (or just duck typing):
>>> isinstance(bd, MutableMapping)
True
>>> isinstance(dict(), MutableMapping)
True
I think when you ask "and that explains why I only see one callback with my example?" below you want to know why the following happens with our different implementations:
>>> BindedDict(lambda: print('changed'), foo='bar', hello='world').clear()
changed
>>> BoundDict(lambda: print('changed'), foo='bar', hello='world').clear()
changed
changed
This is due to the implementation of MutableMapping.clear
; it loops over the keys in the dictionary, calls popitem
for each one, which in turn calls __delitem__
, which in turns calls the callback. By contrast, your implementation calls the callback only once, because you implement clear
directly and call it from there.
Note that the ABC approach doesn't prevent you from doing this. It's not clear from your question (and you may not yet know) which is the correct behaviour, but you can still come in and override the default implementations provided by the ABC:
class BoundDict(MutableMapping):
"""..."""
...
def clear(self):
self._map.clear() # or e.g. self._map = {}
self._callback()
The reason I recommend using the ABC rather than subclassing dict
is that this gives you sensible default implementations that you can override where you need to, so you only need to worry about where your behaviour differs from the defaults. Fewer methods implemented also means a smaller risk of simple typos like __delitme__
(if you don't provide a required @abstractmethod
you get an error when you try to instantiate the class) and super().__init__(*a, *kw)
.