In the programming world, Duplicate Code is considered evil. There are many ways to merge pieces of code or objects that have similar functionality. One of them is Inheritance.

Definition

Inheritance allows us to create “IS-A” relationship between two or more classes, abstracting common logic into superclasses and extending the superclass with specific details in each subclass.

Basic Inheritance

Technically, every class we create uses Inheritance.

Info

All Python classes are subclasses of the special built-in class named object.

This class provide a little bit of metadata and a few built-in behaviours (such as __init__, __str__, and __repr__, among others.) so Python can treat all objects consistently. To prove that:

class MyClass:
    pass
    
print(issubclass(MyClass, object)) # This will print: True

In Python 3, all classes automatically inherit from object if we don’t explicitly provide a different superclass (or parent class).

  • The Superclass is the class that is being inherited from.
  • The Subclass is the class that inherits from the superclass. it is said that the subclass extends the parent class.

Let’s see some uses of inheritance

Add functionality to an existing class:

Let’s create the Contact class:

from typing import List
 
class Contact:
    all_contacts: List["Contact"] = []
 
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email
        Contact.all_contacts.append(self)
 
    def __repr__(self):
        return f"{self.__class__.__name__}({self.name!r}, {self.email!r})"
 
 
contact1 = Contact("John Doe", "john@example.com")
contact2 = Contact("Jane Doe", "jane@example.com")
 
print(Contact.all_contacts)

Output:

[Contact('John Doe', 'john@example.com'), Contact('Jane Doe', 'jane@example.com')]

Among all the contacts there could be some that are suppliers that we need to order supplies from. We could add an order method to the Contact class, but that would allow people to order things from contacts that are not suppliers. Solution: create a new Supplier class that acts like Contact class, but has an additional order method that accepts a yet-to-be defined Order object:

class Supplier(Contact):
    def order(self, order: "Order") -> None:
        print("{order} send to '{self.name}'")

With this setup, all the contacts, including suppliers, accept a name and email address in their constructor, but only Supplier instances have he order method:

c = Contact("AContactName", "acontact@gmail.com")
s = Supplier("ASupplierName", "asupplier@gmail.com")
 
pprint(c.all_contacts)
print()
s.order("I need pliers")

Output:

[Contact('John Doe', 'john@example.com'),
 Contact('Jane Doe', 'jane@example.com'),
 Contact('AContactName', 'acontact@gmail.com'),
 Supplier('ASupplierName', 'asupplier@gmail.com')]

I need pliers send to 'ASupplierName'

Class Variable

A Class Variable is shared by all instances of this class and it has collected instance of both Contact and Supplier classes.

Extending built-ins

Example 1: Extending list

How to search a specific name within the list of contacts? Instead of instantiating a generic list as our class variable, we create a new ContactList class that extends the built-in list data type:

from __future__ import annotations
from typing import List
 
 
class ContactList(list["Contact"]):
    def search(self, name: str) -> list["Contact"]:
        matching_contacts: list["Contact"] = []
        for contact in self:
            if name in contact.name:
                matching_contacts.append(contact)
        return matching_contacts
    
class Contact:
    all_contacts = ContactList()
 
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email
        Contact.all_contacts.append(self)
 
    def __repr__(self):
        return f"{self.__class__.__name__}({self.name!r}, {self.email!r})"
 
 
contact1 = Contact("John Doe", "john@example.com")
contact2 = Contact("Jane Doe", "jane@example.com")
contact2 = Contact("Mary Jane", "mary@example.com")
 
[c.name for c in Contact.all_contacts.search("Doe")]

Output:

['John Doe', 'Jane Doe']

Example 2: Extending dict

How to tracks the longest key in a dictionary?

from typing import Optional
 
class LongNameDict(dict[str, int]):
    def longest_key(self) -> Optional[str]:
        longest = None
        for key in self:
            if longest is None or len(key) > len(longest):
                longest = key
        return longest
    
 
articles_read = LongNameDict()
articles_read["lucy"] = 42
articles_read["c_c_phillips"] = 6
articles_read["steve"]= 7
 
articles_read.longest_key()

Output:

'c_c_phillips'

You can also use a more generic dictionary with either strings or integers:

from typing import Union
 
class LongNameDict(dict[str, Union[str, int]]):
	...

Overriding and super()

Inheritance is great for adding new behaviour, as we have already seen, and for changing behaviour too.

It’s possible to make a third variable available on initialization by overriding the __init__() method:

class Friend(Contact):
	def __init__(self, name: str, email: str, phone: str) -> None:
		self.name = name
		self.email = email
		self.phone = phone

But that’s not the best solution since duplicated code is created. Indeed we need a way to execute the original __init__() method on the Contact class from inside the new Friend class:

class Friend(Contact):
	def __init__(self, name: str, email: str, phone: str) -> None:
		super().__init__(name, email)
		self.phone = phone
 
	def __repr__(self):
        return f"{self.__class__.__name__}({self.name!r}, {self.email!r}, {self.phone!r})"
 
 
aFriend = Friend("tizio", "tizio@gmail.com", "123456789")
Contact.all_contacts

Output:

[Contact('John Doe', 'john@example.com'),
 Contact('Jane Doe', 'jane@example.com'),
 Contact('Mary Jane', 'mary@example.com'),
 Friend('tizio', 'tizio@gmail.com', '123456789')]

Note that we have also overridden the __repr__() method to guarantee a proper visualization of the new Friend class instances.

Multiple Inheritance

to-do

Polymorphism

https://medium.com/data-bistrot/polymorphism-in-python-object-oriented-programming-c652d8c3b792#:~:text=Python%20for%20AI%2C%20data%20science%20and%20machine%20learning%20Day%206&text=Polymorphism%2C%20a%20core%20concept%20in,to%20the%20same%20method%20call. https://medium.com/@codingcampus/polymorphism-in-python-with-examples-887e2d45327a Polymorphism means taking different forms. In programming, it enables operators, functions, and methods to act differently when subjected to different conditions.

Polymorphism in Python can manifest in some ways.

1. Duck Typing

Python is known for its “Duck Typing” Philosophy, which is a form of polymorphism where the type or class of an object is less important than the methods it defines. When you use an object’s method without knowing its type, as long as the object supports the method invocation, Python will run it. This is often summarized as “If looks like a duck and quacks like a duck, it must be a duck”.

Example 1: + operator. The + operator can perform addition two numbers or concatenate two strings depending on the operand types:

result = 1 + 2
print(result) # Output: 3
 
result = "Hello + World!"
print(result) # Output: Hello World!

The + operator’s polymorphism capability enables it to identify the inputs and perform operations accordingly.

Example 2: Python built-in functions. Python built-in functions provide polymorphism, such as the len() function:

print(len("Hello World!")) # Output: 12
print(len([1,2,3,4])) # Output: 4
print(len({"Name": "John", "Surname": "Doe"})) # Output: 2

The len() function works differently when passed different input.

2. Polymorphism with class methods

We can free to create our own functions that show polymorphism. In this example, we’ll create class methods to showcase polymorphism:

class Dog:
	def speak(self):
		print("Woof!")
 
class Cat:
	def speak(self):
		print("Meow!")
 
def animal_sound(animal):
	animal.speak()
 
 
aDog = Dog()
aCat = Cat()
animal_sound(aDog)
animal_sound(aCat)

In this example, the animal_sound function can call the speak method on any object passed to it, demonstrating polymorphism through duck typing. The function does not need to know the type of the object in advance, only that it can perform the action expected of it.

3. Polymorphism with Inheritance

When a child class extends a superclass, it uses method overriding polymorphism to implement inheritance. Basically, polymorphism allows different classes to have methods with the same name but with different implementations:

from abc import ABC, abstractmethod
 
class DataProcessor(ABC):
	@abstractmethod
	def process_data(self, data):
		pass
 
class NumericDataProcessor(DataProcessor):
	def process_data(self, data):
		return [x*2 for x in data]
 
class TextDataProcessor(DataProcessor):
	def process_data(self, data):
		return [s.upper() for s in data]
 
def process_all(data_processor, data):
	return data_processor.process_data(data)
 
 
numeric_processor = NumericDataProcessor()
text_processor = TextDataProcessor()
numeric_data = [1, 2, 3, 4]
text_data = ['python', 'data']
 
print(process_all(numeric_processor, numeric_data))
print(process_all(text_processor, text_data))

Output:

[2, 4, 6, 8]
['PYTHON', 'DATA']

In this example, the process_all function doesn’t need to know the type of data it is processing. It relies on polymorphism to call the appropriate process_data method based on the object it passed to it.