Search code examples
pythonpython-3.xdesign-patternssuperreadonly

Python class function return super()


So I was messing around with a readonly-modifyable class pattern which is pretty common in java. It involves creating a base class containig readonly properties, and extending that class for a modifyable version. Usually there is a function readonly() or something simular to revert a modifyable-version back to a readonly-version of itself.

Unfortunately you cannot directly define a setter for a property defined in a super class in python, but you can simply redefine it as shown below. Mor interestingly In python you got the 'magic function' super returning a proxy-object of the parent/super class which allows for a cheecky readonly() implementation which feels really hacky, but as far as I can test it just works.

class Readonly:
    _t:int
    
    def __init__(self, t: int):
        self._t = t
    
    @property
    def t(self) -> int:
        return self._t



class Modifyable(Readonly):
    def __init__(self, t: int):
        super().__init__(t)
    
    @property
    def t(self) -> int:
        return self._t # can also be super().t
    
    @t.setter
    def t(self, t):
        self._t = t
    
    def readonly(self) -> Readonly:
        return super()

In the above pattern I can call the readonly function and obtain a proxy to the parent object without having to instantiate a readonly version. The only problem would be that when setting the readonly-attribute instead of throwing AttributeError: can't set attribute, it will throw a AttributeError: 'super' object has no attribute '<param-name>'

So here are the question for this:

  • Can this cause problems (exposing the super proxy-object outside the class itself)?
  • I tested this on python 3.8.5, but not sure if that is by accident and goes against the 'python-semantics' so to speak?
  • Is there a better/more desirable way to achieve this? (I have no idea if its even worth the ambiguous error message in ragards to performance for example)

I Would love to hear opinions and/or insights into this


Solution

  • Can this cause problems (exposing the super proxy-object outside the class itself)?

    Not directly. But a super() object sometimes can behave in strange ways sometimes. If all you want are attributes guarded by property it will work, but, for example, if you have a simple instance attribute, accessed directly, (as is the more usual pattern in Python), it won't work with the super() proxy.

    I tested this on python 3.8.5, but not sure if that is by accident and goes against the 'python-semantics' so to speak?

    No, the behaviors you are relying on are pretty consistent, from 3.0 (and even before) onwards (including Python 3.12 alpha)

    Is there a better/more desirable way to achieve this? (I have no idea if its even worth the ambiguous error message in ragards to performance for example)

    Yes. As you can see, this will not deliver a readonly version of your sub-class, it will rather return an super-proxy, which will behave mostly as an instance of the parent class.

    If returning an instance of the parent class is enough, and being modifiable are the only differences, you can create a new read-only instance, load it with the current instance values - this would work:

    def readonly(self):
        # retrieves the superclass without name hardcoding
        # this is the actual class, not a proxy:
        parent = __class__.__mro__[1]
        # creates an instance without going though it's `__init__`:
        instance = parent.__new__(parent)
        # updates the instance `__dict__` internally:
        instance.__dict__.update(self.__dict__)
        return instance
    

    The __dict__.update() thing to effectuate the clonning will look unsafe, but it is pretty reliable. Python serialization tool - pickle - itself uses this approach when unserializing objects (by default - classes can customize that.

    Another option, this is "less recomended" because...maybe it can scare people, but is pretty reliable, and will work, is to change your instance itself to be an instance of ReadOnly when .readonly is called.

    Just write readonly as:

    der readonly(self):
         self.__class__ = __class__.__mro__[1]
         return self
    

    If changing the class is not desirable at all, there is the option to have a state in the "modifiable" class itself that would switch it being writable or not. Upon callign readonly you could either create a new read-only instance, or change the state internally.

    The simpler way of doing this is just use another attribute, prefixed with _, and test it with an if in the setter.

    If there are a lot of such attributes, of course that is undersirable, you can customize __setattr__ to catch all assignements -or create a custom-descriptor, instead of property to do that.

    TBH, if the my goal is to have an editable object that at some point should no longer be editable, I would go with the __class__ changing approach above. If you are using typing, use typing.cast along with the .__class__ assignment so that static tools can understand it.


    update

    This got accepted more than one year later, and upon getting back here, I just got a new approach: It is possible to have a super - read-only class, which directly shares the instance __dict__ with an original mutable class - it is as simple as:

    class A:
        @property 
        def a(self):
            return self._a
    class B:
        # note how to create a new property,
        # keeping the "getter"  of the superclass:
        @A.a.setter
        def a(self, value):
            self._a = value
        @property
        def readonly(self):
            # a new, non initialized instance: 
            a = A.__new__(A)
            # which will share the same namespace,
            # but lack any setters:
            a.__dict__ = self.__dict__
            return a
    # and this can tested on the repl:
    
    b = B()
    b.a = 23
    
    a = b.readonly
    print(a.a)
    a.a = 42
    # raises AttributeError
    b.a = 42  # also updates a.a