Search code examples
pythonclassoopcompositionpython-descriptors

Composition: why are "container instance" attributes not updated when "contained instance" are changed?


I would like to better understand the relation between container object attributes and contained object attributes.

I would like to understand the output of this example:

I create a class Car and a class Tire.
Tire also has an attribute age that should "point" to the attribute of the instance of car named bmw. So I would think that by changing the attribute of bmw instance the pirelli attribute age should also be updated since it references to the same variable. As visible from the example it is not the case.

How could I do to actually have it updated? I am planning of using composition multiple times so in the last class it would be very impractical to use

finalInstance.age = previousInstances.tire.car.age.

class Car:
    def __init__(self, age):
        self.age = age

class Tire:
    def __init__(self, car):
        self.car = car
        self.age = self.car.age

bmw = Car(age=1)
print('Car age is ',bmw.age)

pirelli = Tire(car=bmw)
print('Tire age is ',pirelli.age)

bmw.age = 2
print('Car age is now ',bmw.age)
print('Tire age is now ',pirelli.age)

I would expect this output:

Car age is  1
Tire age is  1
Car age is now  2
Tire age is now  2

But what I get is:

Car age is  1
Tire age is  1
Car age is now  2
Tire age is now  1

Anyone can help me find a way to have self.age = self.car.age of Tire to automatically update?

Thank you a lot.


Solution

  • The assignment operator = in Python really changes the object the name assigned to points to.

    In other words, in your example, the line self.age = self.car.age points to the number (an object) defined in self.car when that line is run. When you later run bmw.age = 2 that changes the attribute in the car class - it points to a new object. But bmw.car.age is not changed.

    You have to first understand that well, and that the plain and simple thing to do is just use, even in the Tire class, the full path to the attribute self.car.age.

    Then if you really think you need the attributes to change dynamically, we have to resort to "reactive" programming - an approach in which one change in an state automatically propagate to other places in the code. Python allows this to be implemented in a number of ways. An example follows.

    So, one of the powerful mechanisms Python has to deal with this kind of thing is the "descriptor protocol" - the language specifies that if an object tied as a class attribute - that is, defined in the class body, not just inside the __init__ or other method - features a __get__ method (or a couple other special named methods), retrieving or trying to rebind (with =) that attribute in an instance of that class will go through this object in the class. It is called a "descriptor".

    Python's property decorator uses this mechanism to provide the most used case of creating setters and getters for an attribute. But since you will want the same access and getting rules for a lot of similar attributes, it makes more sense to create an specialised descriptor class - that will "know" the source of an attribute, and always go to its source to get or set its value.

    The code bellow, along with the "Car, Tire & Screw" classes provide an example of this:

    class LinkedAttribute:
        def __init__(self, container_path):
            self.container_path = container_path
    
        def __set_name__(self, owner, name):
            self.name = name
    
        def _get_container(self, instance):
            container = instance
            for component in self.container_path.split("."):
                container = getattr(container, component)
            return container
    
        def __get__(self, instance, owner):
            if instance is None:
                return self
            container = self._get_container(instance)
            return getattr(container, self.name)
    
        def __set__(self, instance, value):
            container = self._get_container(instance)
            setattr(container, self.name, value)
    
    
    
    class Car:
        def __init__(self, age):
            self.age = age
    
    class Tire:
        age = LinkedAttribute("car")
    
        def __init__(self, car):
            self.car = car
            self.age = self.car.age
    
    class Screw:
        age = LinkedAttribute("tire.car")
    
        def __init__(self, tire):
            self.tire = tire
    
    

    And in the interactive interpreter we have:

    In [37]: bmw = Car(2) 
        ...:                                                                                     
    
    In [38]: t1 = Tire(bmw) 
        ...:                                                                                     
    
    In [39]: s1 = Screw(t1)                                                                      
    
    In [40]: t1.age                                                                              
    Out[40]: 2
    
    In [41]: bmw.age = 5                                                                         
    
    In [42]: t1.age                                                                              
    Out[42]: 5
    
    In [43]: s1.age                                                                              
    Out[43]: 5
    
    In [44]: s1.age = 10                                                                         
    
    In [45]: bmw.age                                                                             
    Out[45]: 10
    

    And, of course, if you don't want to allow any of the parent's attributes to be modified from the contained classes, just leave out (or put a guard flag) on the __set__ method.