<jupyter_start><jupyter_text>Introduction  **What?** Inheritance in all its flavours  super() and __mro__ - **First tool**: everything descent from `object`- **Second rule**: Python computes a method resolution order (MRO) based on your class inheritance tree. - The MRO satisfies 3 properties:    - Children of a class come before their parents    - Left parents come before right parents    - A class only appears once in the MRO- When a method is called, the first occurrence of that method in the MRO is the one that is called. - Any class that doesn't implement that method is skipped. Any call to super within that method will call the next occurrence of that method in the MRO. - Consequently, it matters both what order you place classes in inheritance, **AND** where you put the calls to super in the methods.- **Cons**: It can be argued that using `super` here makes the code less explicit. Making code less explicit violates The Zen of Python, which states, "Explicit is better than implicit."- **Pros**: There is a maintainability argument that can be made for `super` even in single inheritance. If for whatever reason your child class changes its inheritance pattern (i.e., parent class changes or there's a shift to multiple inheritance) then there's no need find and replace all the lingering references to `ParentClass.method_name()`; the use of `super` will allow all the changes to flow through with the change in the class statement.  Example 1  - Accessing parent class object attribues from child class.<jupyter_code>class Parent():
    def __init__(self):
        self.ParentObjectAttribute = 1

class Child(Parent):
    def __init__(self):
        self.ChildObjectAttribute = 2
        Parent.__init__(self)

        # here you can access myvar like below.
        print("Calling from inside __init__", self.ParentObjectAttribute)
child = Child()
print(child.ChildObjectAttribute)
print(child.ParentObjectAttribute)
class Parent():
    def __init__(self):
        self.ParentObjectAttribute = 1

class Child(Parent):
    def __init__(self):
        self.ChildObjectAttribute = 2
        super().__init__()

        # here you can access myvar like below.
        print("Calling from inside __init__", self.ParentObjectAttribute)

child = Child()
print(child.ChildObjectAttribute)
print(child.ParentObjectAttribute)<jupyter_output>Calling from inside __init__ 1
2
1<jupyter_text>Example 2  - There is also a way to directly call each inherited class.<jupyter_code>class Parent1():
    def __init__(self):
        self.Parent1ObjectAttribute = 1

class Parent2():
    def __init__(self):
        self.Parent2ObjectAttribute = 2
        
class Child(Parent1, Parent2):
    def __init__(self):
        self.ChildObjectAttribute = 3
        Parent2.__init__(self)
        Parent1.__init__(self)

        print("Calling from __init__")
        print(self.ChildObjectAttribute)
        print(self.Parent1ObjectAttribute)
        print(self.Parent2ObjectAttribute)

child = Child()
print("Calling from object")
print(child.ChildObjectAttribute)
print(child.Parent1ObjectAttribute)
print(child.Parent2ObjectAttribute)
Child.__mro__
child.__dict__<jupyter_output><empty_output><jupyter_text>- Super is **about** following the chain of inheritance, not getting to a specific class's method.- Multiple inheritance is the **ONLY** case where super() is of any use. - I would not recommend using it with classes using linear inheritance, where it's just **useless** overhead. - But this argument can be challenged and you'll find many developer not using it.s<jupyter_code>"""
Can we achieve the same with super()?
"""
class Parent1():
    def __init__(self):
        print("calling Parent1")
        super(Parent1, self).__init__()
        self.Parent1ObjectAttribute = 1

class Parent2():
    def __init__(self):
        print("calling Parent2")
        super(Parent2, self).__init__()
        self.Parent2ObjectAttribute = 2
        
class Child(Parent1, Parent2):
    def __init__(self):
        super(Child, self).__init__()
        self.ChildObjectAttribute = 3        

        print("Calling from __init__")
        print(self.ChildObjectAttribute)
        print(self.Parent1ObjectAttribute)
        print(self.Parent2ObjectAttribute)

child = Child()
print("Calling from object")
print(child.ChildObjectAttribute)
print(child.Parent1ObjectAttribute)
print(child.Parent2ObjectAttribute)
child.__dict__
Child.__mro__<jupyter_output><empty_output><jupyter_text>Example 3<jupyter_code>class Parent():
    def __init__(self):
        super(Parent, self).__init__()
        print("parent")

class Left(Parent):
    def __init__(self):
        super(Left, self).__init__()
        print("left")

class Right(Parent):
    def __init__(self):
        super(Right, self).__init__()
        print("right")

class Child(Left, Right):
    def __init__(self):
        super(Child, self).__init__()
        print("child")
Child()
"""
With super last in each method.
So it impportant where this is placed under 
__init__
"""
class Parent():
    def __init__(self):
        print("parent")
        super(Parent, self).__init__()

class Left(Parent):
    def __init__(self):
        print("left")
        super(Left, self).__init__()

class Right(Parent):
    def __init__(self):
        print("right")
        super(Right, self).__init__()

class Child(Left, Right):
    def __init__(self):
        print("child")
        super(Child, self).__init__()
Child()
Child.__mro__
class Parent():
    def __init__(self):
        super(Parent, self).__init__()

class Left(Parent):
    def __init__(self):
        super(Left, self).__init__()

class Right(Parent):
    def __init__(self):
        super(Right, self).__init__()
        print("rigt")

class Child(Left, Right):
    def __init__(self):
        super(Child, self).__init__()
Child.__mro__<jupyter_output><empty_output><jupyter_text>Example 4<jupyter_code>class Person:  
    """
    Base class or parent class
    """
    def __init__(self, name):
        """
        Constructor
        """
        self.name = name

    def get_name(self):
        return self.name


class Employee(Person):
    """
    Derived class or subclass
    that uses the base class constructor
    """
    def is_employee_pythonist(self):
        if any([i.lower() == "pythonista" for i in self.name.split(" ")]):
            return True
        else:
            return False
"""
Let's start with object creation/instantiation
for the base clas
"""
person = Person("Pythonista")  
print(person.get_name())
"""
Let's create an object from the children class.
Pay attention that this particular case uses the
base class constructor
"""
employee = Employee("<NAME>")
print(employee.get_name())
print(employee.is_employee_pythonist())<jupyter_output>Employee Pythonista
True<jupyter_text>Example 5  - Inheritance is **transitive** in nature.- This means that if class B inherits from another class A, then all the subclasses of B would automatically inherit from class A.<jupyter_code>class A:
    def method_from_class_a(self):
        print("Hi from class A")


class B(A):
    def method_from_class_b(self):
        print("Hi from class B")


class C(B):
    def method_from_class_c(self):
        print("Hi from class C")
c = C()
c.method_from_class_c()
c.method_from_class_b()
c.method_from_class_a()<jupyter_output>Hi from class C
Hi from class B
Hi from class A<jupyter_text>Example 6<jupyter_code>class A:
    def speak(self):
        print("class A speaking")


class B:
    def scream(self):
        print("class B screaming")


class C(A, B):
    pass
c = C()
c.speak()
c.scream()<jupyter_output>class A speaking
class B screaming<jupyter_text>Example 7  - For a class hierarchy, Python needs to determine which class to use when attempting to access an attribute by name. To do this, Python considers the ordering of base classes.<jupyter_code>class A:
    def speak(self):
        print("class A speaking")


class B:
    def speak(self):
        print("class B speaking")


class C(A, B):
    pass

c = C()
c.speak()
class A:
    def speak(self):
        print("class A speaking")


class B:
    def speak(self):
        print("class B speaking")


class C(B, A):
    pass

c = C()
c.speak()
class A:
    def speak(self):
        print("class A speaking")


class B:
    def speak(self):
        print("class B speaking")


class C(B, A):
    """
    Speak method of class C will override previous speak methods. 
    """
    def speak(self):
        print("class C speaking")

c = C()
c.speak()<jupyter_output>class C speaking<jupyter_text>Example 8  - Sometimes we need to call methods of parent class to a overridden method of child class. - We can achieve this using super function.We can directly use methods of super class or modify them(this is very common).<jupyter_code>class A:
    def test(self):
        return 'A'


class B(A):
    # override test method
    def test(self):  
        # access method of parent class to overridden child class
        return "B" + super().test()  


print(B().test())<jupyter_output>BA<jupyter_text>- There is another **equivalent way** of achieving what we did above - Python 3 encourages using `super()`, instead of using `super(className, self)`, both have the same effect. - Python 2, only supports the super(className,self) syntax. Since, Python 2 is widely used so Python 3 also has support for this type of super calling.<jupyter_code>class B(A):
    def test(self):
        return "B" + super(B, self).test() 
        #super(className,object)
print(B().test())<jupyter_output>BA<jupyter_text>Example 9  - Python supports different types of inheritance so sometimes it needs to be introspected cleanly.- `isinstance()` takes two arguments: an object and a class and returns `True` if the given class is anywhere in the inheritance chain of the object’s class.<jupyter_code>class A:
    def test(self):
        return 'A'


class B(A):
    def test(self):
        return "B" + super(B, self).test()


print(isinstance(A(), A))
print(isinstance(B(), A))
print(issubclass(A, A))
print(issubclass(B, A))
print(issubclass(A, B))
print(issubclass(B, B))<jupyter_output>True
True
False
True<jupyter_text>- Is there anything that can help us sout in understanding this?- `__bases__()` provides a tuple of immediate base classes of a class.<jupyter_code>print(A.__bases__)
print(B.__bases__)<jupyter_output>(<class 'object'>,)
(<class '__main__.A'>,)<jupyter_text>- **By default**, every Python class is the subclass of built-in object class.- `__subclasses__()`returns a list of all    the subclasses a class. - As per the case where we ued `__bases__()`, `__subclasses__` only goes **one level deep** from the class we’re working on.<jupyter_code>print(A.__subclasses__())
print(B.__subclasses__())<jupyter_output>[<class '__main__.B'>]
[]<jupyter_text>Example 10<jupyter_code>class Parent:
    def __init__(self):
        self.parent_attribute = 'I am a parent'

    def parent_method(self):
        print('Back in my day...')

class Child(Parent):
    """
    A child class that inherits from Parent
    """
    def __init__(self):
        Parent.__init__(self)
        self.child_attribute = 'I am a child'


# Create an instance of child
child = Child()

# Show attributes and methods of child class
print(child.child_attribute)
print(child.parent_attribute)
child.parent_method()<jupyter_output>I am a child
I am a parent
Back in my day...
{'fraction_non_alphanumeric': 0.1037075900664568, 'fraction_numerical': 0.004809373906960476, 'mean_word_length': 3.6738863914997957, 'pattern_counts': {'":': 0, '<': 50, '<?xml version=': 0, '>': 50, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '14', 'dir': 'jupyter-structured-clean-dedup', 'id': '98420', 'license': 'OLDAP-2.3', 'n_tokens_mistral': 3439, 'n_tokens_neox': 3331, 'n_words': 1165, 'path': 'Inheritance in all its flavours.ipynb', 'repo_name': 'kyaiooiayk/Python-Programming', 'size': '11449'}