Search code examples
python-3.xooppattern-matchingabstract-classmultiple-inheritance

Multiple inheritance: how to correctly override a method? The complementary of the complementary of an object


TL; DR

  • I'm trying to implement the complementary of the complementary of a spatial region
  • The current solution works with a huge side effect
  • The solution is based on multiple inheritance. Which doesn't follow the best practices
  • Probably, for my use case, multiple inheritance is not the way to go due to side effects
  • I'm seeking helps/suggestions that would point me to another approaches (abstract classes, pattern matching, etc)

In detail

I'm trying to implement the following:

  • Consider spatial sets, such as circles and rectangles. And the regions that they occupy are defined by their respective equations
  • Consider also the complementary of such sets. Their region is the whole universe except the "hole" defined by the "original" set
  • Moreover, the complementary of the complementary is the set itself
  • The spatial sets are not static, they can move around (always preserving their shape). In the case of the complementary, this doesn't make much sense and I don't really need this feature for my purposes

I'm treating the spatial sets as classes that have (almost) the same methods: moving and rotating (both of these methods update the attributes); checking if a certain point belongs to the set; etc.

However, my questions arise when trying to implement the complementary. I've tried many different approaches, but unfortunately not even one worked well. And even worse, the implementation that has all the features working, shows strange side effect!

I'll briefly describe what is currently implemented:

  1. Define different classes (Circle, Rectangle, etc)
  2. Define class Complementary, which is a child of the classes mentioned above (multiple inheritance), which the only difference between this class and the former ones is the negation of the region occupied by the respective spatial set

Let me share a simplified version of the code:

class Bbox:
    def __init__(self,cm,w,h):
        self.cm = cm # center of mass
        self.w  = w # bbox width
        self.h  = h # bbox height
        # more stuff here to initialize
        # doesn't matter now
    
    def move_to(self,xy):
        # upd center of mass to point xy
        self.cm = xy

    def rotate(self,alpha):
        # rotate the bounding box (upd the vertices positions)
        # doesn't matter now
        pass

    def ispoint(self,xy):
        # checks if xy point belongs to the bounding box
        
        # For simplicity sake I'll just override the
        # original auxiliary functions of this method
        # with the boolean value True
        return True

class Circle:
    def __init__(self,center,radius):
        self.center = center
        self.radius = radius

    def move_to(self,xy):
        # upd center to point xy
        self.center = xy

    def ispoint(self,xy):
        # check if xy point belongs to the circle
        xy_isinside = (xy[0]-self.center[0])**2 + (xy[1]-   self.center[1])**2 <= self.radius**2
        return xy_isinside

class Complementary(Circle,Bbox):
    # almost sure that the root cause of my problem
    # is the way I'm defining the constructor
    # I've tried many alternatives and this
    # one works ALMOST fine. More on this later
    def __init__(self, term):
        self.term = term
        # this is not the best way the constructors
        # in multiple inheritance are defined

    def ispoint(self,xy):
        # this is the main thing I want
        # I want to negate the region of the term
        # whether this term is the "original" one
        # or the term is already a complementary
        return not self.term.is_point_in(xy)
        # NOTE: I cannot use .super() here, because
        # it doesn't work in the "second" complementary
        # because will always negate the parent's method

## Tests ##
# circle with center (0,0) and radius 1
circ = Circle([0,0],1)
# complementary of the circle (whole universe with circ as the "hole")
circ_c  = Complementary(circ)
# complementary of the complementary: equivalent to the circ itself
circ_cc = Complementary(circ_c)

# bounding box with center (0,0) and width=2,height=4
bb = Bbox([0,0],2,4)
# complementary of the bbox
bb_c = Complementary(bb)
# complementary of the complementary: equivalent to the bbox itself
bb_cc = Complementary(bb_c)

# Test .ispoint() method #
print(f"is (0,0) in circ {circ.ispoint([0,0])}") # must be true
print(f"is (0,0) in circ_c {circ_c.ispoint([0,0])}") # must be false
print(f"is (0,0) in circ_cc {circ_cc.ispoint([0,0])};") # must be true
# working as intended

print(f"is (1,2) in bb {bb.ispoint([1,2])}") # must be true
print(f"is (1,2) in bb_c {bb_c.ispoint([1,2])}") # must be false
print(f"is (1,2) in bb_cc {bb_cc.ispoint([1,2])};") # must be true
# working as intended
# NOTE that Python is picking the "correct" .ispoint() method,
# the one respective to the Bbox class

Now, the problem arises when I try to call an attribute of a Complementary object. As an example: try to call circ_c.center, and it throws an error saying that circ_c doesn't have such attribute. However, if first call .move_to() method, then get the attribute, it works just fine:

circ_c.center
circ_c.move_to([0,0])
circ_c.center

printscreen of the error

I guess python is just dynamically adding new attributes to objects of the class Complementary by initializing them when calling a method that explicitly initialize that same attribute. Note that .ispoint() uses the center attribute, however .move_to() explicitly initializes center (recall the method definition).

So, long story short, the way I'm using multiple inheritance is causing huge side effects that I don't want. Probably due to the non-conventional way of the __init__ method of the Complementary class (I'm totally aware of that).

I'm seeking any help to my problem. I'm convinced that probably inheritance is not the way to go. Some solutions that are in my head:

  1. A friend suggested to use pattern matching (a more functional approach), however this suggestion was so vague that I can't wrap my head around it.
  2. Use Abstract classes, which seems promising to me. However, I'm not clearly seeing where the class Complementary would fit in this case.
  3. Somehow use pattern matching with abstract classes
  4. Somehow implement a .complementary() method (in classes Circle and Bbox) that would support the "complementary of the complementary" (a double negation).

Thank you for your time.


Solution

  • First of all, you don't want multiple inheritance. A Complementary region is not both a Circle and BBox at the same time! If anything, you should define a class ComplementaryCircle(Circle) and a second class ComplementaryBBox(BBox).

    However, given your usage of

    # complementary of the circle (whole universe with circ as the "hole")
    circ_c  = Complementary(circ)
    

    what you actually want is to use the decorator pattern. The decorator class is a wrapper, it is neither a subclass of Circle nor of BBox, although it does implement the same interface as them. So it's just

    class Complementary:
        def __init__(self, term):
            self.term = term
    
        def move_to(self, xy):
            self.term.move_to(xy)
        def rotate(self, alpha):
            self.term.rotate(alpha)
    
        def is_point_in(self,xy):
            return not self.term.is_point_in(xy)
    

    If you want to use abstract classes, introducing one abstract class for that interface would be the way to go. It could even implement that complementary method:

    from abc import ABC, abstractmethod
    class Region(ABC):
        @abstractmethod
        def move_to(self, xy):
            """update the center of mass to point xy"""
            pass
        @abstractmethod
        def rotate(self, alpha):
            """rotate the bounding box (upd the vertices positions)"""
            pass
        @abstractmethod
        def is_point_in(self, xy):
            """checks if xy point belongs to the bounding box"""
            return False
    
        def complementary(self):
            return Complementary(self)
    

    You'd have class BBox(Region) and class Circle(Region), each implementing those abstract methods. And you'd have your decorator

    class Complementary(Region):
        def __init__(self, term): # as before
            self.term = term
        def move_to(self, xy): # as before
            self.term.move_to(xy)
        def rotate(self, alpha): # as before
            self.term.rotate(alpha)
        def is_point_in(self,xy): # as before
            return not self.term.is_point_in(xy)
    
        def complimentary(self):
            return self.term # an optimisation