Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Subclassing: Is it possible to override a property with a conventional attribute?

Tags:

Let's assume we want to create a family of classes which are different implementations or specializations of an overarching concept. Let's assume there is a plausible default implementation for some derived properties. We'd want to put this into a base class

class Math_Set_Base:     @property     def size(self):         return len(self.elements) 

So a subclass will automatically be able to count its elements in this rather silly example

class Concrete_Math_Set(Math_Set_Base):     def __init__(self,*elements):         self.elements = elements  Concrete_Math_Set(1,2,3).size # 3 

But what if a subclass doesn't want to use this default? This does not work:

import math  class Square_Integers_Below(Math_Set_Base):     def __init__(self,cap):         self.size = int(math.sqrt(cap))  Square_Integers_Below(7) # Traceback (most recent call last): #   File "<stdin>", line 1, in <module> #   File "<stdin>", line 3, in __init__ # AttributeError: can't set attribute 

I realize there are ways to override a property with a property, but I'd like to avoid that. Because the purpose of the base class is to make life as easy as possible for its user, not to add bloat by imposing a (from the subclass's narrow point of view) convoluted and superfluous access method.

Can it be done? If not what's the next best solution?

like image 980
Paul Panzer Avatar asked Apr 19 '20 04:04

Paul Panzer


1 Answers

This will be a long winded answer that might only serve to be complimentary... but your question took me for a ride down the rabbit hole so I'd like to share my findings (and pain) as well.

You might ultimately find this answer not helpful to your actual problem. In fact, my conclusion is that - I wouldn't do this at all. Having said that, the background to this conclusion might entertain you a bit, since you're looking for more details.


Addressing some misconception

The first answer, while correct in most cases, is not always the case. For instance, consider this class:

class Foo:     def __init__(self):         self.name = 'Foo!'         @property         def inst_prop():             return f'Retrieving {self.name}'         self.inst_prop = inst_prop 

inst_prop, while being a property, is irrevocably an instance attribute:

>>> Foo.inst_prop Traceback (most recent call last):   File "<pyshell#60>", line 1, in <module>     Foo.inst_prop AttributeError: type object 'Foo' has no attribute 'inst_prop' >>> Foo().inst_prop <property object at 0x032B93F0> >>> Foo().inst_prop.fget() 'Retrieving Foo!' 

It all depends where your property is defined in the first place. If your @property is defined within the class "scope" (or really, the namespace), it becomes a class attribute. In my example, the class itself isn't aware of any inst_prop until instantiated. Of course, it's not very useful as a property at all here.


But first, let's address your comment on inheritance resolution...

So how exactly does inheritance factor into this issue? This following article dives a little bit into the topic, and the Method Resolution Order is somewhat related, though it discusses mostly the Breadth of inheritance instead of Depth.

Combined with our finding, given these below setup:

@property def some_prop(self):     return "Family property"  class Grandparent:     culture = some_prop     world_view = some_prop  class Parent(Grandparent):     world_view = "Parent's new world_view"  class Child(Parent):     def __init__(self):         try:             self.world_view = "Child's new world_view"             self.culture = "Child's new culture"         except AttributeError as exc:             print(exc)             self.__dict__['culture'] = "Child's desired new culture" 

Imagine what happens when these lines are executed:

print("Instantiating Child class...") c = Child() print(f'c.__dict__ is: {c.__dict__}') print(f'Child.__dict__ is: {Child.__dict__}') print(f'c.world_view is: {c.world_view}') print(f'Child.world_view is: {Child.world_view}') print(f'c.culture is: {c.culture}') print(f'Child.culture is: {Child.culture}') 

The result is thus:

Instantiating Child class... can't set attribute c.__dict__ is: {'world_view': "Child's new world_view", 'culture': "Child's desired new culture"} Child.__dict__ is: {'__module__': '__main__', '__init__': <function Child.__init__ at 0x0068ECD8>, '__doc__': None} c.world_view is: Child's new world_view Child.world_view is: Parent's new world_view c.culture is: Family property Child.culture is: <property object at 0x00694C00> 

Notice how:

  1. self.world_view was able to be applied, while self.culture failed
  2. culture does not exist in Child.__dict__ (the mappingproxy of the class, not to be confused with the instance __dict__)
  3. Even though culture exists in c.__dict__, it is not referenced.

You might be able to guess why - world_view was overwritten by Parent class as a non-property, so Child was able to overwrite it as well. Meanwhile, since culture is inherited, it only exists within the mappingproxy of Grandparent:

Grandparent.__dict__ is: {     '__module__': '__main__',      'culture': <property object at 0x00694C00>,      'world_view': <property object at 0x00694C00>,      ... } 

In fact if you try to remove Parent.culture:

>>> del Parent.culture Traceback (most recent call last):   File "<pyshell#67>", line 1, in <module>     del Parent.culture AttributeError: culture 

You will notice it doesn't even exist for Parent. Because the object is directly referring back to Grandparent.culture.


So, what about the Resolution Order?

So we are interested to observe the actual Resolution Order, let's try removing Parent.world_view instead:

del Parent.world_view print(f'c.world_view is: {c.world_view}') print(f'Child.world_view is: {Child.world_view}') 

Wonder what the result is?

c.world_view is: Family property Child.world_view is: <property object at 0x00694C00> 

It reverted back to Grandparent's world_view property, even though we had successfully manage to assign the self.world_view before! But what if we forcefully change world_view at the class level, like the other answer? What if we delete it? What if we assign the current class attribute to be a property?

Child.world_view = "Child's independent world_view" print(f'c.world_view is: {c.world_view}') print(f'Child.world_view is: {Child.world_view}')  del c.world_view print(f'c.world_view is: {c.world_view}') print(f'Child.world_view is: {Child.world_view}')  Child.world_view = property(lambda self: "Child's own property") print(f'c.world_view is: {c.world_view}') print(f'Child.world_view is: {Child.world_view}') 

The result is:

# Creating Child's own world view c.world_view is: Child's new world_view Child.world_view is: Child's independent world_view  # Deleting Child instance's world view c.world_view is: Child's independent world_view Child.world_view is: Child's independent world_view  # Changing Child's world view to the property c.world_view is: Child's own property Child.world_view is: <property object at 0x020071B0> 

This is interesting because c.world_view is restored to its instance attribute, while Child.world_view is the one we assigned. After removing the instance attribute, it reverts to the class attribute. And after reassigning the Child.world_view to the property, we instantly lose access to the instance attribute.

Therefore, we can surmise the following resolution order:

  1. If a class attribute exists and it is a property, retrieve its value via getter or fget (more on this later). Current class first to Base class last.
  2. Otherwise, if an instance attribute exist, retrieve the instance attribute value.
  3. Else, retrieve the non-property class attribute. Current class first to Base class last.

In that case, let's remove the root property:

del Grandparent.culture print(f'c.culture is: {c.culture}') print(f'Child.culture is: {Child.culture}') 

Which gives:

c.culture is: Child's desired new culture Traceback (most recent call last):   File "<pyshell#74>", line 1, in <module>     print(f'Child.culture is: {Child.culture}') AttributeError: type object 'Child' has no attribute 'culture' 

Ta-dah! Child now has their own culture based on the forceful insertion into c.__dict__. Child.culture doesn't exist, of course, since it was never defined in Parent or Child class attribute, and Grandparent's was removed.


Is this the root cause of my problem?

Actually, no. The error you're getting, which we're still observing when assigning self.culture, is totally different. But the inheritance order sets the backdrop to the answer - which is the property itself.

Besides the previously mentioned getter method, property also have a few neat tricks up its sleeves. The most relevant in this case is the setter, or fset method, which is triggered by self.culture = ... line. Since your property didn't implement any setter or fget function, python doesn't know what to do, and throws an AttributeError instead (i.e. can't set attribute).

If however you implemented a setter method:

@property def some_prop(self):     return "Family property"  @some_prop.setter def some_prop(self, val):     print(f"property setter is called!")     # do something else... 

When instantiating the Child class you will get:

Instantiating Child class... property setter is called! 

Instead of receiving an AttributeError, you are now actually calling the some_prop.setter method. Which gives you more control over your object... with our previous findings, we know that we need to have a class attribute overwritten before it reaches the property. This could be implemented within the base class as a trigger. Here's a fresh example:

class Grandparent:     @property     def culture(self):         return "Family property"          # add a setter method     @culture.setter     def culture(self, val):         print('Fine, have your own culture')         # overwrite the child class attribute         type(self).culture = None         self.culture = val  class Parent(Grandparent):     pass  class Child(Parent):     def __init__(self):         self.culture = "I'm a millennial!"  c = Child() print(c.culture) 

Which results in:

Fine, have your own culture I'm a millennial! 

TA-DAH! You can now overwrite your own instance attribute over an inherited property!


So, problem solved?

... Not really. The problem with this approach is, now you can't have a proper setter method. There are cases where you do want to set values on your property. But now whenever you set self.culture = ... it will always overwrite whatever function you defined in the getter (which in this instance, really is just the @property wrapped portion. You can add in more nuanced measures, but one way or another it'll always involve more than just self.culture = .... e.g.:

class Grandparent:     # ...     @culture.setter     def culture(self, val):         if isinstance(val, tuple):             if val[1]:                 print('Fine, have your own culture')                 type(self).culture = None                 self.culture = val[0]         else:             raise AttributeError("Oh no you don't")  # ...  class Child(Parent):     def __init__(self):         try:             # Usual setter             self.culture = "I'm a Gen X!"         except AttributeError:             # Trigger the overwrite condition             self.culture = "I'm a Boomer!", True 

It's waaaaay more complicated than the other answer, size = None at the class level.

You could also consider writing your own descriptor instead to handle the __get__ and __set__, or additional methods. But at the end of the day, when self.culture is referenced, the __get__ will always be triggered first, and when self.culture = ... is referenced, __set__ will always be triggered first. There's no getting around it as far as I've tried.


The crux of the issue, IMO

The problem I see here is - you can't have your cake and eat it too. property is meant like a descriptor with convenient access from methods like getattr or setattr. If you also want these methods to achieve a different purpose, you're just asking for trouble. I would perhaps rethink the approach:

  1. Do I really need a property for this?
  2. Could a method serve me any differently?
  3. If I need a property, is there any reason I would need to overwrite it?
  4. Does the subclass really belong in the same family if these property don't apply?
  5. If I do need to overwrite any/all propertys, would a separate method serve me better than simply reassigning, since reassigning can accidentally void the propertys?

For point 5, my approach would be have an overwrite_prop() method in the base class that overwrite the current class attribute so that the property will no longer be triggered:

class Grandparent:     # ...     def overwrite_props(self):         # reassign class attributes         type(self).size = None         type(self).len = None         # other properties, if necessary  # ...  # Usage class Child(Parent):     def __init__(self):         self.overwrite_props()         self.size = 5         self.len = 10 

As you can see, while still a bit contrived, it is at least more explicit than a cryptic size = None. That said, ultimately, I wouldn't overwrite the property at all, and would reconsider my design from the root.

If you have made it this far - thank you for walking this journey with me. It was a fun little exercise.

like image 162
r.ook Avatar answered Sep 18 '22 16:09

r.ook