16  Object-Oriented Programming in Python

16.1 Introduction to Object-Oriented Programming

Object-Oriented Programming (OOP) is a programming paradigm that models real-world entities using objects and classes. While procedural programming focuses on functions and the sequence of operations, OOP centers around organizing code into objects, which encapsulate both data and behavior. This approach makes it easier to manage, extend, and modify large and complex programs, while also allowing for better reuse of code.

To understand OOP at a conceptual level, it can be helpful to look at examples from everyday life. These examples will illustrate the key concepts of OOP: classes, objects, attributes, and methods. Later in this chapter, we will see how these ideas map onto programming.

16.1.1 Objects and Classes in Everyday Life

Imagine you’re organizing a car dealership. Every car on the lot can be seen as an object—a specific instance of a general concept, such as a car. The general concept is what we refer to as a class in OOP. A class acts like a blueprint or template for creating objects. Each car on the lot is unique in some way, but they all share certain common traits and behaviors, just like objects created from a class.

Let’s break this down:

  • Class: A car can be described by a blueprint, which details its general characteristics (such as make, model, engine type, etc.) and behaviors (like starting the engine, honking the horn). In OOP, this blueprint is the class.
  • Object: Each specific car you have on the lot—a 2021 Toyota Camry, a 2020 Honda Civic, etc.—is an object created from the class blueprint. These cars have specific values for their characteristics (for example, one car is blue, another has a sunroof).

This distinction between class and object is fundamental to OOP. A class provides the general description, while an object is a specific instance created from that description.

16.1.2 Attributes and Methods

Objects have attributes and methods. Attributes are the data that describe the state of an object, while methods are the actions or behaviors an object can perform.

Continuing with our car dealership analogy:

  • Attributes: Each car has specific properties like color, make, model, year, and mileage. These properties are the attributes of the car object.
  • Methods: The behaviors of a car, such as starting the engine, honking the horn, or accelerating, are the methods. In the class blueprint, methods describe what a car can do, and in OOP terms, they are the actions or functions that can be performed on or by an object.

Just as a car’s attributes are set when the car is manufactured, an object’s attributes are defined when the object is created from the class.

Example: OOP in a Library System

Consider a library system, which can also be modeled using OOP principles. In this case, books are objects, and LibraryBook might be the class that defines their shared characteristics and behaviors.

  • Class (LibraryBook): The class defines the general properties of a book, such as its title, author, publication date, and ISBN. It also defines behaviors, such as checking the book out or returning it.
  • Objects: Each specific book in the library—like “To Kill a Mockingbird” or “Pride and Prejudice”—is an object created from the LibraryBook class. Each object has specific values for its attributes (e.g., title, author) but shares the same behaviors (e.g., can be checked out or returned).

When you borrow a book from the library, the system doesn’t just perform random tasks; it interacts with the specific book object, checking if it is available or already checked out. This interaction between the object and its methods is one of the strengths of OOP—it provides a structured and organized way to manage complex systems.

16.1.3 Encapsulation: Keeping Attributes and Methods Together

One of the key advantages of OOP is encapsulation—the concept of bundling data (attributes) and behaviors (methods) together in one entity, the object. This allows for better organization and modularity.

Let’s return to our car example: You can think of each car as a self-contained object. If you want to interact with the car, you don’t need to know every intricate detail about its inner workings; you just need to use the car’s methods. For instance, to start the car, you don’t have to understand how the engine works—you just turn the key or press a button, and the car performs the appropriate behavior.

In programming, encapsulation works in a similar way. When a class is designed, its internal workings (the code and data) are hidden from other parts of the program. Instead, the class provides a clear interface (methods) that can be used to interact with its objects. This hiding of internal details makes the system easier to maintain and modify, because changes to the internal structure of a class won’t affect the rest of the program as long as the interface remains the same.

Real-World Example: A Bank Account System

Consider a BankAccount class in an online banking system. This class defines the shared characteristics of all bank accounts (e.g., account number, balance) and the behaviors that all accounts can perform (e.g., deposit money, withdraw money, check balance).

  • Class (BankAccount): This acts as the blueprint for creating specific bank accounts. It defines that each account will have an account number, a balance, and methods to deposit or withdraw funds.
  • Objects: Each customer’s bank account is an object created from the BankAccount class. For example, one object might represent Alice’s bank account, while another represents Bob’s bank account.

The concept of encapsulation is crucial here. You, as the user of the bank account, don’t need to know how the bank stores your balance or how it processes withdrawals internally. All you need to know is that when you call the deposit() method, money is added to your account, and when you call the withdraw() method, money is subtracted. The internal details (data storage, security checks) are hidden from you.

This separation of concerns allows bank developers to modify the internal workings of the bank system without affecting the customers’ interactions. For example, the bank might switch to a more efficient algorithm for handling transactions, but as long as the deposit and withdraw methods work the same way, the change is invisible to the user.

16.1.4 Why Use OOP?

Now that we’ve seen how OOP relates to real-world examples, let’s summarize why this approach is so beneficial in programming:

  1. Modularity and Reusability: By encapsulating data and behavior within classes, OOP promotes modular code that can be reused across different programs. For instance, a class written to represent a “student” in a school system can be reused in other systems (e.g., a library system).

  2. Ease of Maintenance: Because objects are self-contained and interact with the rest of the system through defined methods, changes to one part of the system are less likely to break the entire program. This makes OOP programs easier to maintain and scale.

  3. Flexibility Through Inheritance: OOP allows classes to be extended and specialized through inheritance, a powerful feature that we will discuss in detail later. This allows for the creation of complex systems that can be easily modified and extended.

  4. Real-World Modeling: OOP allows programmers to model real-world systems in a way that’s intuitive and easy to understand. Objects in OOP can directly correspond to real-world entities, making the design and development process more natural.

In the next section, we will explore how to define a class in Python, bringing these concepts into the programming world and showing how to create and work with objects.

16.2 Defining a Class

Now that we’ve explored the key concepts behind Object-Oriented Programming (OOP) and how they relate to everyday examples, it’s time to move into the practical side: defining a class in Python. A class is the fundamental building block of OOP, serving as the blueprint for creating objects. Each object created from a class shares the structure and behavior defined by that class, but with unique values for its attributes.

In this section, we will dive into the syntax of class definition in Python, demonstrate how to create classes, and explain how attributes and methods are defined within a class. As you go through this section, keep in mind the real-world analogies from the previous section to understand how Python classes mimic real-world entities.

16.2.1 Basic Class Syntax

In Python, defining a class is straightforward. The keyword class is used, followed by the class name and a colon. The body of the class contains the attributes (data) and methods (functions) that define the behavior of objects created from the class.

Here’s a simple example:

class Car:
    pass

This is the simplest possible class definition: a class named Car with no attributes or methods. The keyword pass is used as a placeholder, indicating that there is no functionality yet. We will build on this example to make it more meaningful.

16.2.2 The __init__ Method: Class Constructor

In most cases, you’ll want your class to initialize some values when an object is created. This is where the __init__ method comes into play. The __init__ method is known as the constructor, and it is automatically called when an object is instantiated from the class. It is used to set up initial values for the object’s attributes.

Let’s expand on our Car class:

class Car:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

Here’s what’s happening:

  • The __init__ method takes several arguments: self, make, model, and year.
  • self refers to the specific object being created. It allows you to assign the values to the object’s attributes.
  • Inside the method, self.make, self.model, and self.year refer to the attributes of the object. We assign the values passed in (make, model, and year) to those attributes.

This way, each object (or instance) of the Car class will have its own make, model, and year attributes, which are set when the object is created.

Example: Creating Objects from a Class

Let’s create some Car objects using this class:

car1 = Car("Toyota", "Camry", 2021)
car2 = Car("Honda", "Civic", 2020)

print(car1.make)  
print(car2.year)  
Toyota
2020

Here, car1 and car2 are objects created from the Car class. Each object has its own set of attributes: car1 is a Toyota Camry from 2021, and car2 is a Honda Civic from 2020. We can access these attributes using dot notation (car1.make, car2.year).

This structure mirrors real-world examples: just as each car in a dealership has specific properties, each object in OOP has specific values for its attributes, even though they are all created from the same class blueprint.

16.3 Methods and Attributes in a Class

In addition to attributes, a class can define methods—functions that describe the behaviors or actions the objects can perform. Methods in a class always take self as the first argument, which refers to the instance on which the method is being called.

Let’s add a method to the Car class that allows the car to display its full description:

class Car:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

    def description(self):
        return f"{self.year} {self.make} {self.model}"

In this example, the method description returns a formatted string with the car’s year, make, and model.

car1 = Car("Toyota", "Camry", 2021)
print(car1.description())  
2021 Toyota Camry

Here, we define the method description inside the class. When we call car1.description(), the car provides its full description. This method operates on the instance of the class (e.g., car1), and it has access to the attributes via self.

16.3.1 Instance Attributes vs. Class Attributes

In Python, attributes can be classified into two types: instance attributes and class attributes.

  • Instance Attributes: These are specific to each object and are defined inside the __init__ method. For example, in the Car class, make, model, and year are instance attributes. Each car object has its own unique values for these attributes.

  • Class Attributes: These are shared across all instances of a class. They are defined directly within the class but outside of any method. All objects of the class will share the same value for a class attribute, unless it is explicitly overridden in an instance.

Let’s modify the Car class to include a class attribute:

class Car:
    wheels = 4  # Class attribute

    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

    def description(self):
        return f"{self.year} {self.make} {self.model}"

In this case, wheels is a class attribute, meaning that all cars created from the Car class will have 4 wheels. This attribute is shared among all instances of the class:

car1 = Car("Toyota", "Camry", 2021)
car2 = Car("Honda", "Civic", 2020)

print(car1.wheels)  
print(car2.wheels)  
4
4

Class attributes can be useful for defining properties that are the same across all instances, such as the number of wheels for cars.

16.3.2 Modifying Attributes

It is possible to modify both instance and class attributes after an object has been created. Let’s see how this works.

Modifying Instance Attributes

car1 = Car("Toyota", "Camry", 2021)
print(car1.description())  

# Modifying the year
car1.year = 2022
print(car1.description())  
2021 Toyota Camry
2022 Toyota Camry

Here, we update the year attribute for car1, and the object reflects the new value when we call the description method again.

Modifying Class Attributes

Class attributes can be modified directly using the class name. Any change to a class attribute will be reflected across all instances of the class:

Car.wheels = 3
print(car1.wheels)
3

By modifying the wheels class attribute, we’ve updated the number of wheels for all Car objects.

16.4 Inheritance

Inheritance is one of the most powerful features of Object-Oriented Programming (OOP). It allows a new class to inherit the properties and behaviors (attributes and methods) of an existing class. The new class, known as the subclass or child class, can also have additional attributes and methods or override existing ones from the parent class. This promotes code reuse and makes it easier to build complex systems by extending functionality rather than starting from scratch.

To understand inheritance conceptually, let’s once again consider some real-world examples.

16.4.1 Inheritance in Everyday Life

Imagine a university system where you have students and graduate students. Both graduate and undergraduate students share many characteristics: they have a name, student ID, and GPA. However, graduate students also have some unique attributes, such as a thesis title and an advisor. It would be redundant to create separate classes for students and graduate students from scratch because many of their attributes overlap.

Instead, we can create a general Student class and a more specialized GraduateStudent class. The GraduateStudent class can inherit the common attributes and behaviors from the Student class, while adding its own unique features. This hierarchical structure is a natural fit for OOP and is made possible through inheritance.

16.4.2 Defining Inheritance in Python

In Python, inheritance is defined by specifying the parent class in parentheses when defining the child class. The child class inherits all the attributes and methods from the parent class.

Let’s define a basic example:

class Student:
    def __init__(self, name, student_id, gpa):
        self.name = name
        self.student_id = student_id
        self.gpa = gpa

    def display_info(self):
        return f"Student: {self.name}, ID: {self.student_id}, GPA: {self.gpa}"

Here, the Student class defines three attributes (name, student_id, and gpa) and one method (display_info). Now, we will define a subclass, GraduateStudent, which will inherit from Student.

class GraduateStudent(Student):
    def __init__(self, name, student_id, gpa, thesis_title):
        super().__init__(name, student_id, gpa)
        self.thesis_title = thesis_title

    def display_thesis(self):
        return f"Thesis: {self.thesis_title}"

In this example:

  • The GraduateStudent class inherits from Student. We can see this by the syntax class GraduateStudent(Student).
  • The __init__ method of the GraduateStudent class uses super().__init__() to call the parent class’s constructor, inheriting the initialization of name, student_id, and gpa.
  • A new attribute, thesis_title, is added, along with a method display_thesis() to show the thesis title.

Example: Creating Instances of Subclasses

Now let’s create instances of the Student and GraduateStudent classes to see how inheritance works in practice:

# Creating a Student object
student1 = Student("Alice", "S12345", 3.9)
print(student1.display_info())  

# Creating a GraduateStudent object
grad_student1 = GraduateStudent("Bob", "G54321", 4.0, "Quantum Computing")
print(grad_student1.display_info())  

print(grad_student1.display_thesis())  
Student: Alice, ID: S12345, GPA: 3.9
Student: Bob, ID: G54321, GPA: 4.0
Thesis: Quantum Computing

As shown, GraduateStudent inherits the display_info() method from the Student class, and we can also use the display_thesis() method that is specific to GraduateStudent.

16.4.3 Overriding Methods

One of the most useful aspects of inheritance is the ability to override methods from the parent class in the child class. This allows the child class to modify or extend the behavior of a method without affecting the parent class.

Let’s say we want the GraduateStudent class to display more detailed information about the student, including the thesis title, when calling the display_info() method. We can override this method in the GraduateStudent class:

class GraduateStudent(Student):
    def __init__(self, name, student_id, gpa, thesis_title):
        super().__init__(name, student_id, gpa)
        self.thesis_title = thesis_title

    # Overriding the display_info method
    def display_info(self):
        return f"Graduate Student: {self.name}, ID: {self.student_id}, GPA: {self.gpa}, Thesis: {self.thesis_title}"

Now, when display_info() is called on a GraduateStudent object, it uses the overridden version of the method instead of the parent class’s version:

grad_student1 = GraduateStudent("Bob", "G54321", 4.0, "Quantum Computing")
print(grad_student1.display_info())  
Graduate Student: Bob, ID: G54321, GPA: 4.0, Thesis: Quantum Computing

In this case, we have enhanced the method from the parent class to include additional information about the student’s thesis.

16.4.4 The super() Function

The super() function is crucial when working with inheritance. It allows you to call methods from the parent class, which is especially useful when overriding methods. As we saw in the example above, super() was used to call the parent class’s __init__() method, so we didn’t have to duplicate the code that initializes the common attributes.

Here’s a breakdown of when to use super():

  • Constructor Chaining: When defining a child class, you often need to initialize the attributes of the parent class. Using super() ensures that the parent class’s __init__() method is called automatically, making it easier to maintain your code.
  • Overriding Methods: If a method in the child class overrides a method in the parent class but you still need access to the parent class’s version of the method, super() allows you to call it.
class GraduateStudent(Student):
    def __init__(self, name, student_id, gpa, thesis_title):
        super().__init__(name, student_id, gpa)
        self.thesis_title = thesis_title

    def display_info(self):
        return super().display_info() + f", Thesis: {self.thesis_title}"

In this case, we use super().display_info() to call the parent class’s method and then add additional information about the thesis. This keeps the code for displaying the basic student information centralized in the Student class, avoiding code duplication.

16.4.5 Multiple Inheritance

Python supports multiple inheritance, which allows a class to inherit from more than one parent class. While this can be a powerful tool, it should be used with caution because it can lead to more complex and harder-to-maintain code. Let’s look at an example:

class Athlete:
    def __init__(self, sport):
        self.sport = sport

    def show_sport(self):
        return f"Sport: {self.sport}"

class StudentAthlete(Student, Athlete):
    def __init__(self, name, student_id, gpa, sport):
        Student.__init__(self, name, student_id, gpa)
        Athlete.__init__(self, sport)

    def display_info(self):
        return f"{self.name}, ID: {self.student_id}, GPA: {self.gpa}, Sport: {self.sport}"

In this example, the StudentAthlete class inherits from both Student and Athlete. This allows the student-athlete to have both academic information (name, ID, GPA) and athletic information (sport). However, notice that we have to explicitly call both Student.__init__() and Athlete.__init__() because both parent classes need to be initialized.

student_athlete1 = StudentAthlete("Charlie", "S98765", 3.8, "Basketball")
print(student_athlete1.display_info())  
Charlie, ID: S98765, GPA: 3.8, Sport: Basketball

In Python, multiple inheritance is resolved using a method resolution order (MRO), which determines the order in which classes are checked when calling a method. Python uses a depth-first, left-to-right search through the inheritance chain, ensuring that the proper methods are called.

16.5 Encapsulation and Data Hiding

Encapsulation is a core concept in Object-Oriented Programming (OOP) that refers to bundling data (attributes) and methods (functions) that operate on that data into a single unit called an object. This not only helps with organization but also protects the internal state of an object from unintended interference or misuse. Encapsulation allows objects to expose only necessary information to the outside world while hiding their internal workings.

This idea is similar to how most real-world objects work: you interact with them in simple ways, without needing to know all the underlying mechanics. Let’s explore this further with examples, and then move on to the specific programming techniques used to implement encapsulation in Python.

16.5.1 Data Hiding: The Foundation of Encapsulation

In programming, data hiding is the practice of restricting access to certain attributes and methods within an object. Data hiding is a key aspect of encapsulation, as it ensures that an object’s internal state cannot be altered in unintended or unpredictable ways by external code. In Python, we achieve data hiding by using certain naming conventions.

  • Public attributes: These attributes and methods are accessible from outside the class and can be used or modified freely. By default, all attributes in Python are public unless specified otherwise.
  • Private attributes: Private attributes are intended to be hidden from outside the class. In Python, we make an attribute private by prefixing its name with a double underscore (__).

Let’s see how this works in practice with a simple example.

Example: Bank Account with Private Balance

class BankAccount:
    def __init__(self, account_number, initial_balance):
        self.account_number = account_number  # Public attribute
        self.__balance = initial_balance  # Private attribute

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
        else:
            raise ValueError("Deposit amount must be positive")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
        else:
            raise ValueError("Insufficient funds or invalid amount")

    def get_balance(self):
        return self.__balance

In this example: - account_number is a public attribute, meaning anyone can access and modify it. - __balance is a private attribute, meaning it cannot be accessed or modified directly from outside the class. - The class provides public methods like deposit(), withdraw(), and get_balance() to interact with the private balance in a controlled way.

Let’s see how this works in action:

# Creating a bank account
account = BankAccount("12345", 1000)

# Accessing public attribute
print(account.account_number)  
12345

# Accessing private attribute directly (raises AttributeError)
print(account.__balance)  # Raises: AttributeError: 'BankAccount' object has no attribute '__balance'
# Accessing balance through the public method
print(account.get_balance())  

# Depositing money
account.deposit(500)
print(account.get_balance()) 

# Withdrawing money
account.withdraw(200)
print(account.get_balance())  
1000
1500
1300

As you can see, attempting to directly access __balance raises an error, demonstrating that the attribute is hidden from outside access. Instead, users of the BankAccount class must interact with the balance through the deposit(), withdraw(), and get_balance() methods. This ensures that the balance is only modified in valid ways, preserving the integrity of the object.

16.5.2 Getter and Setter Methods

Encapsulation does not mean that the data inside an object is completely inaccessible. Often, we need a way to safely access or modify private attributes. In such cases, classes can provide getter and setter methods to expose specific attributes to the outside world, while still maintaining control over how they are accessed or modified.

Let’s modify our BankAccount class to include a setter for the balance attribute:

class BankAccount:
    def __init__(self, account_number, initial_balance):
        self.account_number = account_number
        self.__balance = initial_balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
        else:
            raise ValueError("Deposit amount must be positive")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
        else:
            raise ValueError("Insufficient funds or invalid amount")

    # Getter for balance
    def get_balance(self):
        return self.__balance

    # Setter for balance (with validation)
    def set_balance(self, amount):
        if amount >= 0:
            self.__balance = amount
        else:
            raise ValueError("Balance cannot be negative")

Now we have added a set_balance() method to allow controlled modification of the private __balance attribute. The setter ensures that the balance cannot be set to a negative value, preserving the consistency of the account’s data.

# Setting the balance directly using the setter method
account.set_balance(2000)
# Trying to set a negative balance (raises ValueError)
account.set_balance(-500)  # Raises: ValueError: Balance cannot be negative

By using getter and setter methods, we provide a controlled interface for accessing and modifying private data. This helps prevent improper use of an object’s data, enforcing rules and validation where necessary.

16.6 Polymorphism

Polymorphism is another fundamental concept in Object-Oriented Programming (OOP). The term “polymorphism” comes from the Greek words poly (meaning “many”) and morph (meaning “forms”). In programming, polymorphism allows objects of different classes to be treated as objects of a common parent class, while still preserving their individual behaviors. The primary benefit of polymorphism is that it enables the same operation to behave differently on different types of objects.

Polymorphism is essential in creating flexible and reusable code. By allowing methods to take many forms, polymorphism makes it possible to design systems where the exact behavior of an object can vary depending on the object’s class, yet all objects share a common interface. This allows for a more generalized and powerful approach to programming.

16.6.1 Real-World Example: Payment Systems

Imagine a payment processing system for an online store. The store accepts multiple types of payment: credit cards, PayPal, and gift cards. Even though each payment method is different, they all share the same core behavior: they allow customers to make payments. In this scenario, you can create a common interface (or parent class) called Payment, and then create specific subclasses like CreditCardPayment, PayPalPayment, and GiftCardPayment.

Each subclass implements its own version of a process_payment() method, but all can be treated as instances of the Payment class. This is polymorphism in action: the same method name (process_payment()) works on different types of payment objects, each with its own implementation of the behavior.

16.6.2 Polymorphism Through Method Overriding

Polymorphism in OOP is often achieved through method overriding. When a subclass provides a specific implementation of a method that is already defined in its parent class, this is known as overriding. The method in the subclass replaces the method in the parent class, while still sharing the same interface.

Let’s build an example based on our payment processing system.

class Payment:
    def process_payment(self, amount):
        raise NotImplementedError("Subclasses must implement this method")

class CreditCardPayment(Payment):
    def process_payment(self, amount):
        return f"Processing credit card payment of {amount}"

class PayPalPayment(Payment):
    def process_payment(self, amount):
        return f"Processing PayPal payment of {amount}"

class GiftCardPayment(Payment):
    def process_payment(self, amount):
        return f"Processing gift card payment of {amount}"

In this example: - Payment is the parent class, which defines the method process_payment() but leaves its implementation to the subclasses. - The subclasses (CreditCardPayment, PayPalPayment, and GiftCardPayment) each override the process_payment() method with their specific implementations.

Now, let’s see polymorphism in action by creating different payment objects and processing payments through the same interface:

payments = [CreditCardPayment(), PayPalPayment(), GiftCardPayment()]

for payment in payments:
    print(payment.process_payment(100))
Processing credit card payment of 100
Processing PayPal payment of 100
Processing gift card payment of 100

Even though we have three different types of payment objects, they all share the same interface (process_payment()), and we can treat them as instances of the Payment class. This makes the code more flexible and easier to extend.

16.6.3 Polymorphism Through Method Overloading (Not Native to Python)

Some programming languages allow method overloading, where multiple methods in the same class share the same name but have different parameter lists. While Python doesn’t support method overloading natively, polymorphism can still be achieved through other means, such as using default parameters or the *args and **kwargs syntax to handle variable numbers of arguments.

Here’s an example using default parameters to simulate method overloading in Python:

class Calculator:
    def add(self, a, b=0, c=0):
        return a + b + c

In this example, the add() method can be called with one, two, or three arguments, providing flexibility in how the method is used.

calc = Calculator()
print(calc.add(5))      
print(calc.add(5, 10))  
print(calc.add(5, 10, 20)) 
5
15
35

Although Python doesn’t support method overloading in the traditional sense, this technique allows you to achieve similar functionality.

16.6.4 Polymorphism in Python with Duck Typing

Python’s approach to polymorphism relies heavily on a concept called duck typing. Duck typing is based on the principle that “if it looks like a duck and quacks like a duck, it’s probably a duck.” In Python, an object’s suitability for a given operation is determined by whether it has the necessary attributes and methods, rather than its class hierarchy.

Let’s see how duck typing works with an example:

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

def make_animal_speak(animal):
    print(animal.speak())

# Using different objects with the same method
dog = Dog()
cat = Cat()

make_animal_speak(dog) 
make_animal_speak(cat)  
Woof!
Meow!

In this example, both Dog and Cat have a speak() method, and the function make_animal_speak() works with both objects without caring about their specific types. This is a form of polymorphism enabled by duck typing—Python doesn’t require a strict type hierarchy as long as the necessary method (speak()) is implemented.

16.6.5 Advantages of Polymorphism

Polymorphism offers several key benefits that make it a cornerstone of OOP:

  1. Code Reusability: Polymorphism allows you to write code that works with objects of different types but share a common interface. This reduces duplication and promotes reusability.

  2. Extensibility: When new subclasses are added, they automatically work with the existing polymorphic methods without requiring changes to the code that uses the parent class.

  3. Flexibility: Since polymorphism allows methods to adapt to different object types, you can write more general and flexible programs. This makes it easier to build and extend complex systems.

  4. Readability and Maintainability: Polymorphism allows you to design cleaner and more understandable code by abstracting common behaviors into a shared interface.

16.7 Exercises

Exercise 1: Create a Book Class

Define a Book class with the following attributes: - title (str) - author (str) - pages (int) - year_published (int)
Implement a method description() that returns a string with the book’s title, author, and the number of pages. Create a few Book objects and print their descriptions.

Exercise 2: Car Dealership Simulation

Using the Car class example from the chapter, create an inventory system for a car dealership. Define a list of cars with different makes, models, and years. Write a method that searches the inventory and returns all cars from a particular year.

Exercise 3: Create a Vehicle Class with Subclasses

Define a parent class Vehicle with attributes make and model. Then, define two subclasses Truck and Motorcycle, each with additional attributes (Truck has a payload_capacity, and Motorcycle has cc for engine capacity). Both subclasses should override a method vehicle_type() that prints the specific type of vehicle. Create objects for both subclasses and call their methods.

Exercise 4: University System

Expand on the university system example in the chapter by adding a Professor class that inherits from Person. In addition to the Person attributes (e.g., name, age), the Professor class should include a department and courses_taught. Implement a method that prints the professor’s name and department, and a method to add a course to the professor’s list of courses.

Exercise 5: Bank Account with Private Attributes

Create a BankAccount class where the balance is a private attribute. Write getter and setter methods to access and modify the balance, ensuring that the balance can never be negative. Write a method withdraw() that subtracts a specified amount from the balance and a method deposit() to add to the balance. Test these methods to verify the data is encapsulated properly.

Exercise 6: Employee Management System

Create a class Employee that has private attributes name and salary. Implement getter and setter methods for the salary that ensure a minimum salary threshold of $30,000. Write a method to display the employee’s name and salary, and test the setter to ensure no invalid salaries are set.

Exercise 7: Animal Sound System

Create a parent class Animal with a method speak(). Then, create subclasses Dog, Cat, and Bird, each overriding the speak() method to return a sound appropriate to the animal. Write a function that accepts any Animal object and prints the result of calling its speak() method.

Exercise 8: Shape Class with Polymorphism

Define a parent class Shape with a method area(). Create two subclasses, Circle and Rectangle, each overriding the area() method to calculate the respective areas. Write a function that takes a list of shapes and prints their areas using polymorphism.