Search code examples
pythonoopumlclass-diagrambidirectional

Implementing a bidirectional association relationship in Python


In Martin Fowler's, UML Distilled, in the "Bidirectional Association" section, he says:

Implementing a bidirectional association in a programming language is often a little tricky because you have to be sure that both properties are kept synchronized. Using C#, I use code along these lines to implement a bidirectional association:

Code from the book

class Car...
public Person Owner {
get {return _owner;}
set {
if (_owner != null) _owner.friendCars().Remove(this);
_owner = value;
if (_owner != null) _owner.friendCars().Add(this);
}
}
private Person _owner;
...

class Person ...
public IList Cars {
get {return ArrayList.ReadOnly(_cars);}
}
public void AddCar(Car arg) {
arg.Owner = this;
}
private IList _cars = new ArrayList();
internal IList friendCars() {
//should only be used by Car.Owner
return _cars;
}
....

Question 1:

I tried implementing this in python (get_cars() in Person && get_owner_v2() in Car), I want to know if my code can be used to describe a 'bidirectional association' or not, and if not, how should it be modified to do so?

Note: the first version (inspecting the caller's class/object) was working fine until I started creating car object independently and assigning them to owner on two steps (last 4 print statement proves that). The second version uses the licence numeber lno to figure out the owner. Not sure if I did it correctly but it does the work, based on my understanding.

My Implementation:

#trying the inverse-bidirectional association

class Person:
    cars_and_lnos = [] 
    def __init__(self,name):
        self.__cars = []
        self.__cars.append("Dummy")
        self.__name = name

    def add_car(self, *args, obj = None):
        # import inspect
        if not obj:
            car_object = Car(*args)
        else:
            car_object = obj
        Person.cars_and_lnos.append((car_object,self.__name))
        self.__cars.append(car_object)
    
    def __repr__(self):
        return f"{self.__name}"

    def get_cars(self):
        return self.__cars

        

class Car:
    car_count = 0 
    def __init__(self, lno, price ,year, make, model):
        import inspect
        self.__lno = lno
        self.__price = price
        self.__year = year
        self.__make = make
        self.__model = model
        Car.car_count += 1
        self.__car_id = Car.car_count 

        if "self" in inspect.getargvalues(inspect.stack()[1][0]).args :
            self.__owned_by = f"Car (ID:{self.__car_id}) is Owned By: {inspect.stack()[1][0].f_locals['self']}, which is an instance of Class: {inspect.stack()[1][0].f_locals['self'].__class__.__name__}"
        else:
            self.__owned_by = "This car is not owned by anyone."
    
    
    def __repr__(self):
        return f"Car ID: {self.__car_id}."

    def get_specs(self):
        print(f"+{'-'*30}+")
        print(f"""
    Liscense No.: {self.__lno}
    Price: {self.__price}
    Year: {self.__year}
    Make: {self.__make}
    Model: {self.__model}
    
        """)
    
    @property
    def get_lno(self):
        return self.__lno
    
    def get_owner_v1(self):
        # import inspect
        return self.__owned_by
    
    def get_owner_v2(self):
        if Person.cars_and_lnos:
            for tup in Person.cars_and_lnos:
                if self.__lno == tup[0].get_lno:
                    return f"Car (ID: {self.__car_id}) is owned by: {tup[1]}, he is a: {Person.__name__} Class."
            return "[0] This car is not owned by anyone."
        else:
            return "[1] This car is not owned by anyone."

            
        

owner1 = Person("William")    

owner1.add_car("4567781",10000,2012,"Toyota","Corrolla")
owner1.add_car("2137813",8000,2010,"Porshe","GT3")
owner1.get_cars()[1].get_owner_v1()
print(f"{owner1} owns {len(owner1.get_cars())-1} Car(s).")
print(owner1.get_cars()[1].get_owner_v1())
print("=====================================================")


owner2 = Person("Defoe")    
owner2.add_car("8729120",10000,2012,"Dodge","Challenger")
print(f"{owner2} owns {len(owner2.get_cars())-1} Car(s).")
print(owner2.get_cars()[1].get_owner_v1())
print("=====================================================")

car1 = Car("7839291",10000,2012,"Chevrolet","Camaro")
car2 = Car("6271531",10000,2012,"Ford","Mustang")
print(car2.get_owner_v1())

print("=====================================================")

owner3 = Person("Lyan")
owner3.add_car("656721",9000,2013,"Toyota", "Camry")
owner3.add_car("652901",9000,2013,"Nissan", "Sunny")
owner3.add_car("870251",9000,2013,"BMW", "6 Series")

print(owner3.get_cars()[1].get_owner_v2())
print(owner2.get_cars()[1].get_owner_v2())
print(owner1.get_cars()[1].get_owner_v2())
print("=====================================================")

car3 = Car("5424201",10000,2012,"Volks","Eos")

print(car3.get_owner_v1())
print(car3.get_owner_v2())

owner4 = Person("Daphne")
owner4.add_car(obj=car3)

print(car3.get_owner_v1())
print(car3.get_owner_v2())

Question 2:

In this section he says the that these two notations are likely the same:

enter image description here

enter image description here

Is the following notation (without any arrows specified) can also be considered bidirectional relationship?

enter image description here

EDIT:

I understand that a logical refinement to the class diagram would be * to * (many to many), but I'm only concerned about how correct my implementation to the description/design is and how can it be improved, and where the no-arrows association line fit in the picture.


Solution

  • The question 2 is already perfectly answered. So I'll just provide some complementary info on navigability before focusing on question 1.

    What is navigability ?

    The navigability is something which is defined in very broad terms in the UML specs:

    Navigability means that instances participating in links at runtime (instances of an Association) can be accessed efficiently from instances at the other ends of the Association. The precise mechanism by which such efficient access is achieved is implementation specific. If an end is not navigable, access from the other ends may or may not be possible, and if it is, it might not be efficient.

    The navigability expresses some promises or constraints about the implementation. In an early stage it is therefore common not to specify the navigability. In later stages, you'll explicitly show the navigation path that must be supported. But it's rarely done systematically, and moreover, it is rare to see the non-navigability (an X instead of an arrow head).

    Conclusion: when you see no arrow head and no X, you cannot draw any conclusion, unless your team has defined some conventions that are more precise on this situation.

    How is it implemented?

    An association can be implemented in many ways. A typical implementation is to keep in the object of class A one or several references to associated objects of class B. By definition, this ensures navigability A ---> B since there is an efficient access. But what about the way back ?

    If you do nothing else, the object B can not find easily the object A it is associated with. The only way, would be to iterate through all the A objects to find its reference (possible but not efficient) if the reference is not private (which would make the search impossible). So you'd have a non navigable way back A x--> B

    To make it bidirectionally navigable, you would keep in B objects a reference to the associated A object(s). This makes the navigability bidirectional: A <--> B.

    What are the challenges for bidirectional navigability?

    The main challenge here, is that you have to keep the references synchronized in both objects.

    • If Person A keeps a list of owned Cars C1, C2 and C3, reciprocally each Car C1, C2, C3 would keep the reference to their owner A.
    • If you change the owner of car C2 to B, you'd better update the car list of owner A (remove C2), and the car list of owner B (add C2).
    • If B adds to its list of owned car a new car C4, the owner of C4 should be updated accordingly.

    Design issue: each class should be well encapsulated and use the public access of the other class. So Owner.AddCar(Car) and Car.SetOwner(Owner) should be exposed in public interface. But if somewhere you'd invoke B.AddCar(C4), the AddCar() method would want to invoke C4.SetOwner(B) to ensure consitency. But SetOwner() method could also be invoked from outside and woultd therefore want to also keep synchronisation by invoking B.AddCar(C4) and then ... you'll end with a stack overflow of two methods calling each other for ever to maintain synchronisation.

    Fowler's code solved this issue, by giving Car a privileged access to the car list of the Person via the friendCars(). This solves the trick but requires Car to know the internals of Person and create an unnecessary coupling.

    The problem with your code

    I'm not a specilist of Python, but what I understand, is that your code is different:

    • You maintain a class variable cars_and_lnos that holds tuples of all the cars and their owner.
    • The person maintains this list when adding a car to the owned car.
    • the Car's __owned_by is only updated at construction of the car and not afterwards. So it might work when the car is constructed in the adding process based on *arg, but fail when an existing car is added to an owner.
    • accordingly, get_owner_v1() fails because it is based on __owned_by. but get_owner_v2() works well because it iterates (very inefficiently) on cars_and_lnos which is up-to-date.

    In short, the v1 fails because your code produces inconsistent objects.

    This being said, your approach is more promising than Fowler's one:

    • step1: you could move cars_and_lnos into a separate class CarRegistration, and rewrite Car and Person to interact with CarRegistration to update ownerships based on a well designed api.
    • step2: refactor the then working but inefficient CarRegistration to replace cars_and_lnos with 2 dictionaries for optimized search.