Inheritance

If we're writing a new class that needs similar methods as another class, we could copy/paste those. A better way is inheritance. class Child(Parent): means that the new class named Child inherits methods from the previous class named Parent.

In [1]:
class Parent:
    def f(self):
        print("Parent: f")

    def g(self):
        print("Parent: g")

    def h(self):
        print("Parent: h")

class Child(Parent):
    def h(self):
        print("Child: h")
        
youngster = Child()
youngster.f()
youngster.g()
youngster.h()
Parent: f
Parent: g
Child: h

In your mental model, you should imagine the f and g methods being copied to the Child class, like this:

In [2]:
class Child(Parent):
    def f(self):
        print("Parent: f")

    def g(self):
        print("Parent: g")
    
    def h(self):
        print("Child: h")

youngster = Child()
youngster.f()
youngster.g()
youngster.h()
Parent: f
Parent: g
Child: h

The h method doesn't get "copied" from Parent because Child already has a method called that. The technical way to describe this situation is to say that Child overrides the h method of the base class Parent.

Every class we create has a parent. If we don't specify, its parent is a class named object (yes, it is confusing that a class is named object, since we create objects from classes; in Python code, object isn't an object, it's a class).

This means we always inherit some special methods, like __str__.

In [3]:
class C:
    pass

obj = C()

obj.__str__() # inherited from the class named "object"
Out[3]:
'<__main__.C object at 0x7f908edd48d0>'

This is very convenient; otherwise, we wouldn't be able to print obj because doing so implicitly calls __str__:

In [4]:
print(obj)
<__main__.C object at 0x7f908edd48d0>

Multiple Inheritance

Every class we create has at least one parent (object at a minimum), but we're allowed to have 2, 3, or more parents. This capability is called multiple inheritance -- it's a capability that many other programming languages lack.

In [5]:
class Parent1:
    def f(self):
        print("Parent1: f")
        
class Parent2:
    def f(self):
        print("Parent2: f")
        
    def g(self):
        print("Parent2: g")
        
class Parent3:
    def g(self):
        print("Parent3: g")

class Child(Parent1, Parent2, Parent3):
    pass
        
youngster = Child()

This is a confusing situation! For f, which method do we inherit? Both Parent1 and Parent2 have an f method, after all. Similarly, both Parent2 and Parent3 have a g method.

Fortunately, the Child.__mro__ tuple will tell us Python's preferences in terms of where to find methods.

In [6]:
Child.__mro__
Out[6]:
(__main__.Child, __main__.Parent1, __main__.Parent2, __main__.Parent3, object)
In [7]:
for cls in Child.__mro__:
    print(cls)
<class '__main__.Child'>
<class '__main__.Parent1'>
<class '__main__.Parent2'>
<class '__main__.Parent3'>
<class 'object'>

This means we'll use the f method from Parent1 instead of the f method from Parent2, as Parent1 is higher priority (earlier in the tuple). Similarly, we'll use g from Parent2 instead of Parent3. This means we can predict what the following will print:

In [8]:
youngster.f()
youngster.g()
Parent1: f
Parent2: g

__mro__ stands for "Method Resolution Order", and it's a special attribute that every class has. Don't confuse this with the special methods that we've called on our objects. Contrast youngster.__str__() (parentheses are necessary for method calls, special or not) with Child.__mro__ (just an attribute, so no parentheses; also, it's a class attribute, in contrast to object attributes we more frequently encounter).

Python has some rules for determining the method-resolution order:

  1. prefer closer: if both a parent class and grandparent class have the same method, take the version from the parent (which is closer to the child)
  2. prefer left: in class SomeClass(Parent1, Parent2, Parent3), Parent1 appears first (most to the left) in the list, so that is the highest priority; similarly, Parent2 is higher priority than Parent3
  3. prefer descendants: if both A and B are ancestors of our class C via separate paths, and A is an ascestor of B, then B will have priority over A

Situations often arise where the above rules contradict each other. The above rules are from weakest (1) to strongest (3); this allows us to resolve contradictions. Let's see an example:

In [9]:
class Top:
    def f(self):
        print("Top")

class Level_1A(Top):
    pass
        
class Level_1B(Top):
    def f(self):
        print("Level_1B")
        
class Level_2(Level_1B):
    pass

class Level_3(Level_2):
    pass

class Bottom(Level_1A, Level_3):
    pass

b = Bottom()
b.f()
Level_1B

Here, Bottom could inherit f from either Top or Level_1B. Level_1B is a descendent of Top, so the Level_1B version is chosen, according to rule 3.

This shows the strength of rule 3. Rule 1 would have preferred taking f from Top, as Bottom => Level_1A => Top is shorter than Bottom => Level_3 => Level_2 => Level_1B => Top. Rule 2 would have also preferred taking f from Top, as the Bottom => Level_1A => Top path is follows the leftmost parents up. Yet Rule 3 wins and f is taken from Level_1B.

In [ ]: