14. Inheritance#
In this notebook, we cover the following subjects:
Understanding Inheritance;
Single Inheritance;
Method Overriding;
Other Forms of Inheritance.
# To enable type hints for lists, dicts, tuples, and sets we need to import the following:
from typing import List, Dict, Tuple, Set
Up until now, we’ve covered a lot of ground in our Python journey. We’ve explored various data types
, looped through data with for-loops
and while-loops
, crafted functions
to perform specific tasks and much more. But most excitingly, we’ve dived into the world of classes
—our very own blueprint for creating objects.
Remember how we defined classes
to encapsulate data and functions together, making our code cleaner and more modular? Today, we’re going to build on that foundation by introducing a new concept in object-oriented programming (OOP) called Class Inheritance
.
14.1. What is class inheritance
?#
Class Inheritance
is one of the core concepts of OOP that allows us to establish a type of "is-a"
relationship between classes. It enables us to create a new class, known as the derived class
or child class
, that inherits attributes and methods from an existing class, known as the base class
or parent class
. Think of it as a family tree where the child (derived class) inherits traits (attributes or methods) from their parent (base class), but can also have its own unique characteristics.
Why is Class Inheritance Useful?
Real-world Relationships: Inheritance mirrors real-life relationships and hierarchies, making our programs intuitively easier to design and understand.
Code Reusability: With inheritance, we can write a class once and then extend it in other classes. This reduces duplication and errors, making our codebase more robust and maintainable.
Transitivity: Just like in a family tree, if a class B inherits from class A, all subclasses of B will automatically inherit from A as well. This chain can continue, forming a multi-level hierarchy.
Simple and Understandable Model: Inheritance helps organize complex systems into hierarchies that are simpler to manage and extend.
Cost Efficiency: By reusing code and simplifying extensions, inheritance can significantly cut down on development and maintenance costs.
Sounds fun, righ? Let’s see how we can implement class inheritance
in Python.
14.1.1. Syntax of class inheritance
#
Let’s see a high level example of how class
inheritance will work:
# Parent Class
class ParentClass:
'''
Explanation of what the parent class does
'''
def __init__(self, common_attribute1: Any, common_attribute2: Any) -> None:
'''
Docstring
'''
# Initialize common attributes
self.common_attribute1 = common_attribute1
self.common_attribute2 = common_attribute2
def common_method(self) -> Any:
'''
Docstring
'''
# Define a method that can be shared with child classes
return "This is a method in the Parent Class"
# Child Class
class ChildClass(ParentClass):
'''
Explanation of what the child class does
'''
def __init__(self, common_attribute1: Any, common_attribute2: Any, specific_attribute: Any) -> None:
'''
Docstring
'''
# Call the ParentClass constructor using super()
super().__init__(common_attribute1, common_attribute2)
# Initialize child-specific attribute
self.specific_attribute = specific_attribute
def specific_method(self):
'''
Docstring
'''
# Define a method specific to the ChildClass
return "This is a method specific to the Child Class"
So what is happening here? As you can see, we defined 2 classes:
ParentClass
This is like the blueprint for other classes. It has some common features (common_attribute1 and common_attribute2) that any class based on it will inherit. It also has a method called common_method() that just lets you know it’s from the ParentClass.
ChildClass
Think of this as a specialized version of the ParentClass. It gets all the goodies from ParentClass
through a special call, super()
, which means it doesn’t miss out on any features. Plus, it has its own unique feature (specific_attribute) and its own special method (specific_method()), making it a bit more tailored.
In a nutshell: The ChildClass gets everything the ParentClass has, plus a little extra to make it special. It’s like inheriting a family recipe and then adding your own twist to it!
14.2. Single Inheritance#
Now let’s see a proper implementation of class inheritance
:
First, we define our parent class
, Mammal, which includes basic properties and methods that all mammals would have.
class Mammal:
"""
A class representing a mammal with attributes and behaviors common to all mammals.
Attributes:
name (str): The name of the mammal.
age (int): The age of the mammal in years.
habitat (str): The natural habitat where the mammal resides.
warm_blooded (bool): Indicates if the mammal is warm-blooded. Defaults to True.
"""
def __init__(self, name: str, age: int, habitat: str, warm_blooded: bool = True) -> None:
"""
Initializes a new instance of the Mammal class with specified name, age, habitat, and
warm-blooded status.
Parameters:
name (str): The name of the mammal.
age (int): The age of the mammal.
habitat (str): The natural habitat where the mammal resides.
warm_blooded (bool): Indicates if the mammal is warm-blooded. Defaults to True.
"""
self.name = name
self.age = age
self.habitat = habitat
self.warm_blooded = warm_blooded
def __str__(self) -> str:
"""
Returns a string representation of the mammal, providing a description that includes its
name, age, warm-blooded status, and habitat.
Returns:
str: A descriptive string of the mammal.
"""
warm_blood_status = 'warm-blooded' if self.warm_blooded else 'cold-blooded'
return f"{self.name} is a {self.age}-year-old {warm_blood_status} mammal that lives in the {self.habitat}"
Nothing new so far. Let’s create an instance of the Mammal class
to ensure everything is working as expected.
mammal_1: Mammal = Mammal('Lopez', 4, 'Jungle')
print(mammal_1)
Lopez is a 4-year-old warm-blooded mammal that lives in the Jungle
14.2.1. Creating a child class
#
All right, all is good! Now, to define a child class
, we declare a new class and specify the parent class
in parentheses. Here’s how we define our Cat
class that inherits from Mammal
:
class Mammal:
"""
A class representing a mammal with attributes and behaviors common to all mammals.
Attributes:
name (str): The name of the mammal.
age (int): The age of the mammal in years.
habitat (str): The natural habitat where the mammal resides.
warm_blooded (bool): Indicates if the mammal is warm-blooded. Defaults to True.
"""
def __init__(self, name: str, age: int, habitat: str, warm_blooded: bool = True) -> None:
"""
Initializes a new instance of the Mammal class with specified name, age, habitat, and
warm-blooded status.
Parameters:
name (str): The name of the mammal.
age (int): The age of the mammal.
habitat (str): The natural habitat where the mammal resides.
warm_blooded (bool): Indicates if the mammal is warm-blooded. Defaults to True.
"""
self.name = name
self.age = age
self.habitat = habitat
self.warm_blooded = warm_blooded
def __str__(self) -> str:
"""
Returns a string representation of the mammal, providing a description that includes its
name, age, warm-blooded status, and habitat.
Returns:
str: A descriptive string of the mammal.
"""
warm_blood_status = 'warm-blooded' if self.warm_blooded else 'cold-blooded'
return f"{self.name} is a {self.age}-year-old {warm_blood_status} mammal that lives in the {self.habitat}"
So what’s going on here?
The Cat class
starts off by declaring its ancestry— it extends the Mammal
class. We know this becuase of the Cat(Mammal)
Mammal word in the parenthesis after the Cat
class definition. This relationship means that Cat
inherits all the properties
and methods
from Mammal
. Essentially, anything that a Mammal
can do or describe about itself, a Cat
can do too.
14.2.2. Constructor and super()
#
Inside the constructor
of the Cat class
, something new happens with a simple call to super().__init__(name, age, habitat, warm_blooded)
. This isn’t just any function; it’s the golden bridge to the parent class Mammal
. By calling super()
, Cat
ensures that it starts its “life” equipped with all the attributes
and methods
that define a Mammal
. It’s like saying, “Hey, I may be a cat, but I’m a mammal first!” This way, all the essential characteristics like name
, age
, and habitat
are initialized just as they are for any mammal, making sure that our Cat isn’t missing out on any family traits.
But why does this matter?
Using super()
is a great move because it means we write less code and minimize the chances of errors in setting up common properties. It ensures consistency across subclasses
and keeps the initializations in one place. Plus, it leaves room for adding more cat-specific
features (methods or attributes) later on, like a method
to purr or chase a laser pointer!
This approach not only saves time but also keeps our code clean and maintainable. Who knew learning about Cat could be so enlightening? :)
14.2.3. Customizing init()
in subclasses#
When venturing into the world of subclasses
like our Cat class
, one of the first things you might want to do is add new attributes
that make these subclasses special, while still retaining all the great setup provided by the parent class
. Let’s explore how to do this effectively by both customizing and extending the constructor method, __init__()
, in our Cat subclass
.
now, imagine we want to give our Cat
some unique characteristics that aren’t defined in the Mammal class
, like breed
. Here’s how we can add this new attribute:
Declare the Attribute: You decide what new properties your subclass should have, such as breed for cats.
Initialize in
__init__()
: When you write the constructor for theCat class
, you addbreed
as a parameter, and set it just like any other attribute.
Here’s a look at how it’s done:
class Cat(Mammal):
"""
A class representing a cat, which is a specific type of mammal. Inherits from the Mammal class.
Attributes inherited from Mammal:
name (str): The name of the cat.
age (int): The age of the cat in years.
habitat (str): The natural habitat where the cat resides.
warm_blooded (bool): Indicates if the cat is warm-blooded. Defaults to True.
New Attribute:
breed (str): The breed of the cat.
Inherits methods from Mammal, and can use or override them as necessary.
"""
def __init__(self, name: str, age: int, habitat: str, breed: str, warm_blooded: bool = True):
"""
Initializes a new instance of the Cat class, which includes all the attributes of the
Mammal class along with a specific attribute for the breed of the cat.
Parameters:
name (str): The name of the cat.
age (int): The age of the cat.
habitat (str): The natural habitat where the cat resides.
breed (str): The breed of the cat.
warm_blooded (bool): Indicates if the cat is warm-blooded. Defaults to True.
Calls the constructor of the Mammal class to initialize inherited attributes.
"""
super().__init__(name, age, habitat, warm_blooded) # First, call the parent class constructor
self.breed = breed # Now, add the new attribute specific to cats
You might be wondering, why call super().__init__()
?
Calling super().__init__()
is crucial because it executes the parent class’s constructor, ensuring that all the initialization code set up in Mammal class
(like setting up name, age, habitat, and warm_blooded) also applies to instances of Cat
. This way, you don’t have to repeat the initialization code for these attributes in the Cat class
.
However, I hope you noticed that we added a new line bellow the super().__init__()
call.
While super()
handles all the properties from Mammal, immediately after calling super()
, we can set up things that are specific to a Cat
. This setup process might involve initializing new attributes, like breed
, or setting default values that differ from the parent class settings.
I feel like we had enough text for now, let’s finally try out our new Cat
child class!
whiskers: Cat = Cat("Whiskers", 5, "house", "Siamese")
print(whiskers)
Whiskers is a 5-year-old warm-blooded mammal that lives in the house
This look almost perfect, but actually something is missing…
If you are attention to detail, you probably noticed that we did not use the breed
attribute of the Cat class
. So all that hard work was for nothing? Thankfully not.
Let me show you how we can overwrite class methods
.
14.3. Overwriting class methods#
Method overwriting
is a fundamental concept in object-oriented programming that allows a subclass
to provide a specific implementation of a method
that is already defined in its superclass
. This technique is particularly useful for tailoring the behavior of inherited methods to fit the needs of the subclass, enhancing the flexibility and functionality of your code.
Let’s begin by modifying the __str__()
method in the Cat
class to reflect characteristics that are specific to cats, such as their breed
. This is an example of how you can change the default behavior inherited
from the parent class
to better suit the child class's
context.
class Cat(Mammal):
"""
A class representing a cat, which is a specific type of mammal.
Attributes inherited from Mammal:
name (str): The name of the cat.
age (int): The age of the cat in years.
habitat (str): The natural habitat where the cat resides.
warm_blooded (bool): Indicates if the cat is warm-blooded. Defaults to True.
New Attribute:
breed (str): The breed of the cat.
Inherits methods from Mammal, and can use or override them as necessary.
"""
def __init__(self, name: str, age: int, habitat: str, breed: str, warm_blooded: bool = True):
"""
Initializes a new instance of the Cat class, which includes all the attributes of the
Mammal class along with a specific attribute for the breed of the cat.
Parameters:
name (str): The name of the cat.
age (int): The age of the cat.
habitat (str): The natural habitat where the cat resides.
breed (str): The breed of the cat.
warm_blooded (bool): Indicates if the cat is warm-blooded. Defaults to True.
Calls the constructor of the Mammal class to initialize inherited attributes.
"""
super().__init__(name, age, habitat, warm_blooded) # First, call the parent class constructor
self.breed = breed # Now, add the new attribute specific to cats
In this example, the __str__()
method in Cat
first calls the method from Mammal
using super().__str__()
to reuse the general mammal description. Then, it adds a bit more detail about the cat’s breed, providing a more descriptive and useful string representation for objects of the Cat class.
Let’s see it in action:
whiskers: Cat = Cat("Whiskers", 5, "house", "Siamese")
print(whiskers)
Whiskers is a 5-year-old warm-blooded mammal that lives in the house
Let’s consider a few more scenarios where method overriding might be useful:
Animal Sounds: Suppose we have a generic method in a Animal class
that makes a sound. Different animals make different sounds, so you override this method in subclasses like Dog
and Bird
:
# This is a basic Animal class that includes a generic make_sound() method:
class Animal:
"""
A basic class representing an animal with common attributes and behaviors.
Attributes:
name (str): The name of the animal.
age (int): The age of the animal in years.
habitat (str): The natural habitat where the animal resides.
"""
def __init__(self, name: str, age: int, habitat: str) -> None:
"""
Initializes a new instance of the Animal class with specified name, age, and habitat.
Parameters:
name (str): The name of the animal.
age (int): The age of the animal.
habitat (str): The natural habitat where the animal resides.
"""
self.name = name
self.age = age
self.habitat = habitat
def make_sound(self) -> str:
"""
Returns a generic sound that represents a typical animal sound.
Returns:
str: A generic animal sound.
"""
return "Some generic sound"
def __str__(self) -> str:
"""
Returns a string representation of the animal, providing a description that includes its
name, age, and habitat.
Returns:
str: A descriptive string of the animal.
"""
return f"{self.name} is {self.age} years old and lives in {self.habitat}."
Now, the child classes
Dog
and Bird
can overwrite this method to provide specific sounds:
The Dog subclass
will overwrite the make_sound()
method and will use super()
to initialize inherited attributes from Animal
:
class Dog(Animal):
"""
A class representing a dog, which is a specific type of animal. Inherits from the Animal class.
Attributes inherited from Animal:
name (str): The name of the dog.
age (int): The age of the dog in years.
habitat (str): The natural habitat where the dog resides.
New Attribute:
breed (str): The breed of the dog.
"""
def __init__(self, name: str, age: int, habitat: str, breed: str) -> None:
"""
Initializes a new instance of the Dog class, which includes all the attributes of the
Animal class along with a specific attribute for the breed of the dog.
Parameters:
name (str): The name of the dog.
age (int): The age of the dog.
habitat (str): The natural habitat where the dog resides.
breed (str): The breed of the dog.
Calls the constructor of the Animal class to initialize inherited attributes.
"""
super().__init__(name, age, habitat) # Call the parent class constructor
self.breed = breed # Now, add the new attribute specific to dogs
def make_sound(self) -> str:
"""
Returns a sound typical of dogs, overriding the generic animal sound.
Returns:
str: A dog-specific sound.
"""
return "Bark!"
def __str__(self) -> str:
"""
Returns a string representation of the dog, enhancing the base animal description with
details about the dog's breed.
Returns:
str: A descriptive string of the dog including its breed.
"""
base_info = super().__str__() # Get the base animal information
return f"{base_info} It is a {self.breed} dog."
Similarly, the Bird subclass
will overwrite the make_sound method()
, and also use super()
for initialization:
class Bird(Animal):
"""
A class representing a bird, a specific type of animal. Inherits from the Animal class.
Attributes inherited from Animal:
name (str): The name of the bird.
age (int): The age of the bird in years.
habitat (str): The natural habitat where the bird resides.
New Attribute:
can_fly (bool): Indicates whether the bird can fly.
"""
def __init__(self, name: str, age: int, habitat: str, can_fly: bool) -> None:
"""
Initializes a new instance of the Bird class, which includes all the attributes of the
Animal class along with a specific attribute indicating if the bird can fly.
Parameters:
name (str): The name of the bird.
age (int): The age of the bird.
habitat (str): The natural habitat where the bird resides.
can_fly (bool): Indicates whether the bird can fly.
Calls the constructor of the Animal class to initialize inherited attributes.
"""
super().__init__(name, age, habitat) # Call the parent class constructor
self.can_fly = can_fly # Now, add the new attribute specific to birds
def make_sound(self) -> str:
"""
Returns a sound typical of birds, overriding the generic animal sound.
Returns:
str: A bird-specific sound.
"""
return "Chirp!"
def __str__(self) -> str:
"""
Returns a string representation of the bird, enhancing the base animal description with
details about the bird's ability to fly.
Returns:
str: A descriptive string of the bird including its flight capability.
"""
base_info = super().__str__() # Get the base animal information
fly_status = "can fly" if self.can_fly else "cannot fly"
return f"{base_info} It {fly_status}."
Now let’s see what happens when we create a few instances of the different classes:
# parent Animal class instance
test_animal: Animal = Animal('Polly', 6, 'Farm')
print(test_animal.make_sound())
print(test_animal)
print('-'*20)
# Child Dog class insatnce
pluto_dog: Dog = Dog('Pluto', 2, 'House', 'Golden Retriever')
print(pluto_dog.make_sound())
print(pluto_dog)
print('-'*20)
# Child Bird class insatnce
chirpy_bird: Bird = Bird('Chirpy', 1, 'Jungle', False)
print(chirpy_bird.make_sound())
print(chirpy_bird)
Some generic sound
Polly is 6 years old and lives in Farm.
--------------------
Bark!
Pluto is 2 years old and lives in House. It is a Golden Retriever dog.
--------------------
Chirp!
Chirpy is 1 years old and lives in Jungle. It cannot fly.
As we can see, all instances made from the child classes
can make different sounds. Just how we wanted!
14.4. Other Forms of Inheritance#
After exploring how method overwriting
allows subclasses to provide specific implementations of methods defined in their superclass, it’s clear that inheritance can be very powerful for creating flexible and maintainable code. However, Python doesn’t limit us to just single inheritance. There are more complex forms of inheritance that can provide even greater flexibility and control over how behaviors and attributes are passed down through our classes.
Let’s delve into these other forms of inheritance, namely multiple inheritance
and multilevel inheritance
, to see how they can further enhance our object-oriented programming capabilities.
14.4.1. Multiple Inheritance#
Multiple inheritance
allows a class to inherit from more than one parent class, combining their behaviors and attributes into a single class. This form of inheritance can be particularly powerful, but it requires a careful understanding of the Method Resolution Order
(MRO), which determines how Python prioritizes parent classes when searching for methods and attributes.
Let’s see an example:
class Father:
"""
A class representing a father with specific skills.
Attributes:
name (str): The name of the father.
father_skill (str): Skills associated with the father, defaulting to "Gardening, DIY".
"""
def __init__(self, name: str) -> None:
"""
Initializes a new instance of the Father class with a name and default skills.
Parameters:
name (str): The name of the father.
"""
self.name = name
self.father_skill = "Gardening, DIY"
def __str__(self) -> str:
"""
Returns a string representation of the father, including his name and skills.
Returns:
str: Description of the father and his skills.
"""
return f"{self.name}: Skills include {self.father_skill}"
class Mother:
"""
A class representing a mother with specific skills.
Attributes:
name (str): The name of the mother.
mother_skill (str): Skills associated with the mother, defaulting to "Cooking, Art".
"""
def __init__(self, name: str) -> None:
"""
Initializes a new instance of the Mother class with a name and default skills.
Parameters:
name (str): The name of the mother.
"""
self.name = name
self.mother_skill = "Cooking, Art"
def __str__(self) -> str:
"""
Returns a string representation of the mother, including her name and skills.
Returns:
str: Description of the mother and her skills.
"""
return f"{self.name}: Skills include {self.mother_skill}"
class Child(Father, Mother):
"""
A class representing a child inheriting skills from both father and mother.
Inherits attributes and methods from both Father and Mother classes.
"""
def __init__(self, name: str) -> None:
"""
Initializes a new instance of the Child class, inheriting skills from both parents.
Parameters:
name (str): The name of the child.
"""
Father.__init__(self, name) # Initialize Father part of the child
Mother.__init__(self, name) # Initialize Mother part of the child
def __str__(self) -> str:
"""
Returns a string representation of the child, including the name and combined skills from both parents.
Returns:
str: Description of the child and inherited skills.
"""
return f"{self.name}: Child's Skills include {self.father_skill}, {self.mother_skill}"
# Creating an instance of Child
alex = Child("Alex")
print(alex)
Alex: Child's Skills include Gardening, DIY, Cooking, Art
So what’s happening here?
As we can see, Child
inherits from both Father
and Mother
. By initializing both parent classes with name, the Child class combines the skills attributes of both parents into a single comprehensive description. Cool, huh?
To make it easier to understand Multiple Inheritance
, consider the following scenario:
Think of a child with parents from two very different backgrounds—one loves music, and the other is great at sports. This child inherits a mix of these skills, becoming both musically gifted and athletic. In Python, this is like a Child class inheriting methods from both a Musician class and an Athlete class, blending these abilities into a unique skill set.
Hopefully now this is clear, so let me show you one last thing before the exercises.
Multilevel Inheritance
Multilevel inheritance
allows classes to inherit from a class that is already derived from another class, creating a class hierarchy that extends over several generations. This might sound a bit complex for the first time, but don’t worry everything will make sense in a bit.
class Grandparent:
"""
A class representing a grandparent with basic attributes.
Attributes:
name (str): The name of the grandparent.
origin (str): The geographic or cultural origin of the grandparent.
"""
def __init__(self, name: str, origin: str) -> None:
"""
Initializes a new instance of the Grandparent class with a name and origin.
Parameters:
name (str): The name of the grandparent.
origin (str): The origin of the grandparent.
"""
self.name = name
self.origin = origin
def __str__(self) -> str:
"""
Returns a string representation of the grandparent, including their name and origin.
Returns:
str: Description of the grandparent and their origin.
"""
return f"{self.name}: Origin {self.origin}"
class Parent(Grandparent):
"""
A class representing a parent that extends the Grandparent class, adding a new attribute.
Attributes:
hobby (str): The hobby of the parent.
"""
def __init__(self, name: str, origin: str, hobby: str) -> None:
"""
Initializes a new instance of the Parent class, inheriting attributes from the Grandparent class and adding a hobby.
Parameters:
name (str): The name of the parent.
origin (str): The origin of the parent.
hobby (str): The hobby of the parent.
"""
super().__init__(name, origin)
self.hobby = hobby
def __str__(self) -> str:
"""
Returns a string representation of the parent, enhancing the grandparent's description with the hobby.
Returns:
str: Description of the parent including their origin and hobby.
"""
return f"{super().__str__()}, Hobby: {self.hobby}"
class Child(Parent):
"""
A class representing a child that extends the Parent class, adding another new attribute.
Attributes:
school (str): The school that the child attends.
"""
def __init__(self, name: str, origin: str, hobby: str, school: str) -> None:
"""
Initializes a new instance of the Child class, inheriting attributes from the Parent class and adding the school attribute.
Parameters:
name (str): The name of the child.
origin (str): The origin of the child.
hobby (str): The hobby of the child.
school (str): The school the child attends.
"""
super().__init__(name, origin, hobby)
self.school = school
def __str__(self) -> str:
"""
Returns a string representation of the child, enhancing the parent's description with the school.
Returns:
str: Description of the child including their origin, hobby, and school.
"""
return f"{super().__str__()}, School: {self.school}"
lucy = Child("Lucy", "New York", "painting", "Riverside High")
print(lucy)
Lucy: Origin New York, Hobby: painting, School: Riverside High
Let’s break down the above code:
Here, Child
is a third-generation class deriving from Parent
, which itself inherits from Grandparent
. This multilevel inheritance allows Child
to access and extend the attributes and methods of its ancestors, showcasing the hierarchical inheritance model. The __str__()
method in Child
extends its parent’s hobby description, demonstrating how derived classes can enhance or modify inherited properties.
Same as above, let me give you an example that will make it easier for you to understand Multilevel Inheritance
:
Consider a family tradition passed from grandparent to parent to child, with each generation adding something new. The grandparent teaches gardening, the parent adds cooking, and the child integrates technology. In Python, this is mirrored by classes inheriting and extending features over generations.
To wrap it up, lets summarise what we have learnt in this notebook. We’ve explored the concepts of single
, multiple
and multilevel
inheritance in Python, highlighting how classes can inherit and extend attributes and methods. We’ve seen how these inheritance patterns allow for a more organized and reusable codebase, reflecting real-world relationships and hierarchies.
Last but not least, ready your coding gloves—it’s time to put theory into practice, and hopefully, without any inheritance disputes!
14.5. Exercises#
Let’s practice! Mind that each exercise is designed with multiple levels to help you progressively build your skills. Level 1 is the foundational level, designed to be straightforward so that everyone can successfully complete it. In Level 2, we step it up a notch, expecting you to use more complex concepts or combine them in new ways. Finally, in Level 3, we get closest to exam level questions, but we may use some concepts that are not covered in this notebook. However, in programming, you often encounter situations where you’re unsure how to proceed. Fortunately, you can often solve these problems by starting to work on them and figuring things out as you go. Practicing this skill is extremely helpful, so we highly recommend completing these exercises.
For each of the exercises, make sure to add a docstring
and type hints
, and do not import any libraries unless specified otherwise.
14.5.1. Exercise 1#
Level 1: Create a Vehicle class
with attributes make
(str), model
(str), and year
(int). Then, derive two subclasses: Car
with an additional attribute num_doors
(int), and Motorcycle
with an additional attribute has_sidecar
(bool, default is False
). Implement the __str__()
method in each class to display the vehicle’s information.
Example input:
vehicle: Vehicle = Vehicle(make="Toyota", model="Corolla", year=2020)
car: Car = Car(make="Honda", model="Civic", year=2021, num_doors=4)
motorcycle: Motorcycle = Motorcycle(make="Harley-Davidson", model="Street 750", year=2019)
print(vehicle)
print(car)
print(motorcycle)
Example output:
Vehicle: Toyota Corolla, Year: 2020
Car: Honda Civic, Year: 2021, Doors: 4
Motorcycle: Harley-Davidson Street 750, Year: 2019, Sidecar: No
# TODO
Level 2: Extend the previous exercise by creating a new schild class ElectricCar
that inherits from Car
. Include an additional attribute battery_capacity
(int) in kWh, morover, modify the __str__()
method in the ElectricCar
class to include the battery capacity.
Lastly, add a method is_modern_vehicle()
in the Vehicle class
that checks if the vehicle’s manufacturing year is 2015 or later. Override this method in the ElectricCar class
to always return True
(as electric cars are considered modern by default).
Example input: you pass this argument to the parameter in the function call.
car: Car = Car(make="Honda", model="Civic", year=2010, num_doors=4)
electric_car: ElectricCar = ElectricCar(make="Tesla", model="Model 3", year=2022, num_doors=4, battery_capacity=75)
print(car)
print(electric_car)
print(cart(car.is_modern_vehicle())
print(electric_caric_car.is_modern_vehicle())
Example output:
Car: Honda Civic, Year: 2010, Doors: 4
ElectricCar: Tesla Model 3, Year: 2022, Doors: 4, Battery Capacity: 75 kWh
False
True
# TODO.
Level 3: For this task, you will implement a fleet management system that can handle multiple vehicles of different types (Vehicle
, Car
, Motorcycle
, ElectricCar
). This task requires you to:
Create a new class
Fleet
to manage vehicles.Add and manage vehicles in a list of dictionaries, where each dictionary contains (you have to implement this in the
__init__()
):The type of the vehicle (e.g., “Car”, “ElectricCar”, etc.).
The actual object instance.
Example list:
vehicle_management: List[Dict[str, Union[str, Vehicle]]] = [
{"type": "Vehicle", "object": Vehicle(make="Toyota", model="Corolla", year=2020)},
{"type": "Car", "object": Car(make="Honda", model="Civic", year=2010, num_doors=4)},
{"type": "ElectricCar", "object": ElectricCar(make="Tesla", model="Model S", year=2021, num_doors=4, battery_capacity=100)},
{"type": "Motorcycle", "object": Motorcycle(make="Yamaha", model="R1", year=2018, has_sidecar=False)}
]
Implement the following methods in the
Fleet
class:add_vehicle(vehicle: Vehicle)
that adds a vehicle to the fleet.If the object is not an instance of
Vehicle
or its subclasses, raise aTypeError
with the message:"Only objects of type Vehicle or its subclasses can be added to the fleet."
get_all_vehicles()
: that returns a formatted string listing all vehicles in the fleet, grouped by type.find_vehicle_by_model(model: str)
that finds and returns details of a vehicle in the fleet with the specified model name.If no such vehicle is found, raise a
ValueError
with the message:"No vehicle found with model name '<model>'."
Requirements
Use the
Vehicle
,Car
,Motorcycle
, andElectricCar
classes from previous tasks.Ensure
Fleet
manages these objects dynamically.Implement error handling by raising and catching both
TypeError
andValueError
.
Example input: you pass this argument to the parameter in the function call.
# Create fleet
fleet: Fleet = Fleet()
# Create vehicles
vehicle: Vehicle = Vehicle(make="Toyota", model="Corolla", year=2020)
car: Car = Car(make="Honda", model="Civic", year=2010, num_doors=4)
electric_car: ElectricCar = ElectricCar(make="Tesla", model="Model S", year=2021, num_doors=4, battery_capacity=100)
motorcycle: Motorcycle = Motorcycle(make="Yamaha", model="R1", year=2018, has_sidecar=False)
# Add vehicles to fleet
fleet.add_vehicle(vehicle)
fleet.add_vehicle(car)
fleet.add_vehicle(electric_car)
fleet.add_vehicle(motorcycle)
# Display all vehicles
print(fleet.get_all_vehicles())
# Find specific vehicle
print(fleet.find_vehicle_by_model("Model S"))
# Test error handling
try:
fleet.add_vehicle("NotAVehicle") # Invalid object
except TypeError as e:
print(e)
try:
print(fleet.find_vehicle_by_model("UnknownModel")) # Non-existent model
except ValueError as e:
print(e)
Example output:
All Vehicles in Fleet:
- Vehicle: Toyota Corolla, Year: 2020
- Car: Honda Civic, Year: 2010, Doors: 4
- ElectricCar: Tesla Model S, Year: 2021, Doors: 4, Battery Capacity: 100 kWh
- Motorcycle: Yamaha R1, Year: 2018, Sidecar: No
Found Vehicle:
ElectricCar: Tesla Model S, Year: 2021, Doors: 4, Battery Capacity: 100 kWh
TypeError: Only objects of type Vehicle or its subclasses can be added to the fleet.
ValueError: No vehicle found with model name 'UnknownModel'.
# TODO.
14.5.2. Exercise 2#
Level 1: For this exercise, first, create the class called MusicPlayer
that contains the following attributes: name
(string), data_format
(string), and state
(integer).
The default value for name
and data_format
is an empty string (""
). The state
attribute can have three values, 0
, 1
, or 2
, and its default value is 0
:
0
: the music player is playing some music.1
: the music player has been paused.2
: the music player has been stopped.
Then, define the __str__()
method so it returns the following message: “Music Player(<name>
, <data_format>
, <state>
)”.
Finally, create the methods play()
, pause()
, and stop()
, which will change the state
attribute of the music player accordingly.
The play
method should change the state of the music player to 0
, whereas the pause
method and the stop
method should set the state of the music player to 1
and 2
respectively.
Example input:
cd_player: MusicPlayer = MusicPlayer('CD Player', 'CD', 2)
cd_player.play()
print(cd_player)
cd_player.pause()
print(cd_player)
Example utput:
Music Player(CD Player, CD, 0)
Music Player(CD Player, CD, 1)
# TODO.
Level 2: There are different types of music players. For this exercise, you need to create two special types of music players: record and cassette player.
First, create the RecordPlayer
and CassettePlayer
classes, which will inherit from the MusicPlayer
class (created in level 1).
Then, fulfill the following requirements:
The name of all
RecordPlayer
objects must be"Record Player"
.The data format of all
RecordPlayer
objects must be"Record"
.The name of all
CassettePlayer
objects must be"Cassette Player"
.The data format of all
CassettePlayer
objects must be"Cassette"
.Constructors for both classes do not require any parameters.
Modify the
__str__()
method of theRecordPlayer
and theCassettePlayer
classes, so they return the message ‘name
(state
)’.
Example: Input
record_player: RecordPlayer = RecordPlayer()
cassette_player: CassettePlayer = CassettePlayer()
print(record_player)
print(cassette_player)
Output:
Record Player(0)
Cassette Player(0)
# TODO.
Level 3: The record and cassette players are old-school music players. More modern players have new features that can be used by users, such as having a list of songs and playing them independently.
For this exercise create the ModernPlayer
class that inherits from the MusicPlayer
class (created in level 1).
This modern player will get all the music player attributes (name
, data_format
, and state
), plus the songs
attribute as parameters. The songs
attribute is a list of strings representing the titles of the songs. It will also have the current_song
attribute upon initialization, which will keep track of the current song being played. It should start by pointing to the first item on the list.
Furthermore, the ModernPlayer
class has the following two methods:
next_song()
: this method changes the position of the current song to the next song in the list. For instance, if the current song is at position 0, then this method will change the position to 1, thereby representing the next song. If the current song is the last song and we use this method, it should point to the first song afterwards, i.e. starting all over again.play_current_song()
: this method returns the title of the current song of thesongs
attribute. Consider the attribute you created previously.
Example input:
beatles_songs = [
'Can\'t Buy Me Love',
'I Should Have Known Better',
'Paperback Writer',
'Rain',
'Lady Madonna',
'Revolution',
'Hey Jude',
'Old Brown Shoe',
'Don\'t Let Me Down',
'The Ballad of John and Yoko'
]
cd_player: ModernPlayer = ModernPlayer('CD Player', 'CD', 0, beatles_songs)
for _ in range(6):
cd_player.next_song()
print(cd_player.play_current_song())
Example output:
Hey Jude
# TODO.
14.5.3. Exercise 3#
The cosmos is vast and full of fascinating systems like planets, stars, and planetary systems. Let’s build a program to model this.
Level 1: Create a base CelestialObject
class with the following attributes:
name
(string): The name of the object.mass
(float): The mass of the object in kilograms.
Implement a __str__()
method that returns a string describing the CelestialObject, including its name and mass.
Then, create a subclass Planet
that inherits from CelestialObject
. Add the following attributes to Planet
:
radius
(float): The radius of the planet in kilometers.distance_from_star
(float): The distance of the planet from its star in astronomical units (AU).
Override the __str__()
method in Planet to include all relevant planet details.
Example input:
celestial: CelestialObject = CelestialObject(name="Generic Object", mass=1e20)
earth: Planet = Planet(name="Earth", mass=5.972e24, radius=6371, distance_from_star=1.0)
print(celestial)
print(earth)
Example output:
Celestial Object: Generic Object, Mass: 1e+20 kg
Planet: Earth, Mass: 5.972e+24 kg, Radius: 6371 km, Distance from star: 1.0 AU
# TODO.
Level 2: Create a Star
class that inherits from CelestialObject
. Add the following attributes:
type
(string): The type of the star (e.g., “G-type”, “M-type”).luminosity
(float): The luminosity of the star compared to the Sun.
Override the __str__()
method in Star
to include include the star’s type and luminosity.
Next, enhance the Planet
class to associate it with a specific Star
. Add an attribute host_star
that holds the star it orbits.
Update the Planet class’s __str__()
method to include the name of the host star.
Example input:
sun: Star = Star(name="Sun", mass=1.989e30, type="G-type", luminosity=1.0)
earth: Planet = Planet(name="Earth", mass=5.972e24, radius=6371, distance_from_star=1.0)
earth.host_star = sun
print(sun)
print(earth)
Example output:
Star: Sun, Mass: 1.989e+30 kg, Type: G-type, Luminosity: 1.0
Planet: Earth, Mass: 5.972e+24 kg, Radius: 6371 km, Distance from star: 1.0 AU, Host Star: Sun
# TODO.
Level 3: Make a new class: AsteroidBelt
that represents a collection of small celestial objects orbiting a star. It inherits from CelestialObject
and has the following unique attributes and methods:
Attributes:
host_star
(Star): The star this asteroid belt orbits.asteroids
(list): A list of string names representing individual asteroids in the belt.
Methods:
add_asteroid
(name: str): Add an asteroid to the belt.remove_asteroid
(name: str): Remove an asteroid from the belt.__str__()
: Override the__str__()
method to include the asteroid belt’s name, host star, and a list of asteroid names.
Then make a new class: Comet
which represents a celestial object that can pass through multiple star systems. It inherits from CelestialObject
and adds the following unique attributes and methods:
Attributes:
path
(list): A list ofStar
objects that the comet passes by.current_star
(Star): The star closest to the comet at a given time.
Methods:
add_star_to_path()
(star: Star): Add a star to the comet’s path.set_current_star()
(star: Star): Update the comet’s current location.__str__()
: Override the__str__()
method to include the comet’s name, current star, and its full path (list of star names).
Then implement the following function:
Find All Moons in a Star System: Write a function that, given a
Star
object, returns the names of all moons orbiting all planets in that star system.List Comets Passing Through a Star System: Write a function that, given a
Star
object, returns all comets that pass through its system (from the comet’s path attribute).Find Asteroids in All Systems: Write a function that, given a list of
Star
objects, returns a dictionary where the keys are star names and the values are lists of asteroid names in their asteroid belts.
Example input:
# Create stars
sun: Star = Star(name="Sun", mass=1.989e30, type="G-type", luminosity=1.0)
alpha_centauri: Star = Star(name="Alpha Centauri", mass=2.0e30, type="G-type", luminosity=1.0)
# Create planets
earth: Planet = Planet(name="Earth", mass=5.972e24, radius=6371, host_star=sun, distance_from_star=2.0)
jupiter: Planet = Planet(name="Jupiter", mass=1.898e27, radius=69911, host_star=sun, distance_from_star=1.0)
# Add planets to stars
sun.add_planet(earth)
sun.add_planet(jupiter)
# Create moons
luna: Moon = Moon(name="Luna", mass=7.342e22, host_planet=earth)
europa: Moon = Moon(name="Europa", mass=4.8e22, host_planet=jupiter)
# Add moons to planets
earth.add_moon(luna)
jupiter.add_moon(europa)
# Create an asteroid belt for the Sun
main_belt: AsteroidBelt = AsteroidBelt(name="Main Belt", mass=1e21, host_star=sun)
main_belt.add_asteroid("Ceres")
main_belt.add_asteroid("Vesta")
main_belt.add_asteroid("Pallas")
# Create a comet
halley: Comet = Comet(name="Halley's Comet", mass=2.2e14)
halley.add_star_to_path(sun)
halley.add_star_to_path(alpha_centauri)
halley.set_current_star(sun)
# Write functions to explore the system
print("All moons in the Sun's system:", find_all_moons_in_star_system(sun))
print("Comets passing through the Sun's system:", find_comets_in_star_system(sun, [halley]))
print("Asteroids in all systems:", find_asteroids_in_all_systems([sun, alpha_centauri]))
# Print objects
print(main_belt)
print(halley)
Example output:
All moons in the Sun's system: ['Luna', 'Europa']
Comets passing through the Sun's system: ["Halley's Comet"]
Asteroids in all systems: {'Sun': ['Ceres', 'Vesta', 'Pallas'], 'Alpha Centauri': []}
Asteroid Belt: Main Belt, Mass: 1.0e+21 kg, Host Star: Sun
Asteroids: Ceres, Vesta, Pallas
Comet: Halley's Comet, Mass: 2.2e+14 kg, Current Star: Sun
Path: Sun -> Alpha Centauri
# TODO.
Material for the VU Amsterdam course “Introduction to Python Programming” for BSc Artificial Intelligence students. These notebooks are created using the following sources:
Learning Python by Doing: This book, developed by teachers of TU/e Eindhoven and VU Amsterdam, is the main source for the course materials. Code snippets or text explanations from the book may be used in the notebooks, sometimes with slight adjustments.