Search code examples
pythonooppropertiespython-descriptors

Is there a better way of detecting when attributes are changed than using decorators?


I have been refactoring a text class meant for blitting text to the screen. I recently learned about the @property decorator and thought using it in this class would help.
Here is a stripped down version of the code I am writing:

class Text:
    def __init__(self, text, antialias):
        self.text = text
        self.antialias = antialias
        self._render_text()

    @property
    def text(self):
        return self._text

    @text.setter
    def text(self, value):
        self._text = value
        self._requires_render = True

    @property
    def antialias(self):
        return self._antialias

    @antialias.setter
    def antialias(self, value):
        self._antialias = value
        self._requires_render = True

    def _render_text(self):
        self._required_render = False

    def blit(self):
        if self._requires_render:
            self._render_text()
        # blit text to screen

I noticed that my main motivation for using the setters and getters was to change an attribute that ordered the re-render of the text before blitting to the screen. This ensured that changing attributes of the text object correctly updated in the event loop. It also ensured that if multiple properties that all required the text to be re-rendered were called in sucession, only one re-render would be triggered as the re-render check happens only once every gameloop.
However it also seemed excessive and I was wondering if there was a way to write a custom @property object that included the self._requires_render line. Though after some searching and not finding anyone having the same problem, I am considering that my logic may be flawed and there is an easier way of detecting when an object's properties update, and running some code.


Solution

  • Yes - the ideal thing there is to use the same mechanisms used by property itself - but property is not the ideal.

    Both property and normal methods in Python use whatis called the descriptor protocol - which means that the attributes associated in the class can have a __get__ and __set__ method, which are used when accessing the value with the . notation.

    Basically, all you need is a __set__ method that will set the _requires_render attribute, and otherwise allow transparent access to the attribute. You can take advantage of the __set_name__ method which is asscoaited to the descriptor protocol as well, and is called at class creation time by the language:

    class RenderNeeded:
        def __set_name__(self, owner, name):
            self.name = name
        def __get__(self, instance, owner):
            if instance is None:
                 return self
            return instance.__dict__[self.name]
        def __set__(self, instance, value):
            instance._requires_render = True
            instance.__dict__[self.name] = value
    
    
    
    from functools import wraps
    
    
    # and for methods instead of variables,
    # this is the decorator approach:
    
    def clear_rendering(func):
        @wraps(func)
        def wrapper(self, *args, **kw):
            result = func(self, *args, **kw)
            self._requires_render = False
            return result
        return wrapper
       
    class Text:
        def __init__(...):
            ...
        text = RenderNeeded()
        antialias = RenderNeeded()
        
        @clear_rendering
        def blit(self, ...):
            # blit text code ...
            ...
    

    Another approach than creating a customized descriptor is to customize the __setattr__ method, directly in your class:

    class Text:
        def __init__(self, text, antialias):
             ...
        def __setattr__(self, attr, value):
             if attr in {"text", "antialias", ...}:
                   # this triggers a recursive call, but there is no problem. Just use `super().__setattr__` to avoid it, if preferred:
                   self._requires_render = True
              return super().__setattr__(attr, value)