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: TrueIn 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
ContactandSupplierclasses.
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 = phoneBut 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_contactsOutput:
[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: 2The 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.