Understand Class Features in Python

PythonBeginner
Practice Now

Introduction

In this lab, you will gain a practical understanding of key object-oriented programming (OOP) concepts in Python. We will start with encapsulation, learning how to bundle data and methods within a class and control access to data using private attributes.

Next, you will implement inheritance to build relationships between classes, which promotes code reuse. We will also explore polymorphism, which allows objects of different classes to be treated uniformly. Finally, you will use the super() method to effectively call methods from a parent class, and you will practice multiple inheritance to see how a class can inherit from several parent classes.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 100% completion rate. It has received a 100% positive review rate from learners.

Explore Encapsulation with Basic Classes

In this step, we will explore encapsulation, a core OOP principle. Encapsulation involves bundling data (attributes) and the methods that operate on that data into a single unit, a class. It also restricts direct access to an object's internal state, which helps prevent accidental data modification.

In Python, we use a naming convention to indicate that an attribute is "private". Prefixing an attribute with a single underscore (e.g., _name) signals that it is intended for internal use. While not strictly enforced, it is a strong convention that developers respect.

We will start by creating two separate classes, Dog and Cat, to see how they can be structured.

First, locate the file animal_classes.py in the file explorer on the left side of the WebIDE. Open it and add the following Python code. This code defines a Dog class and a Cat class, each with a private _name attribute and methods to interact with it.

## File: animal_classes.py

class Dog:
    def __init__(self, name):
        ## A single underscore prefix indicates a "private" attribute.
        self._name = name

    ## Public method to get the value of the private attribute.
    def get_name(self):
        return self._name

    ## Public method to set the value of the private attribute.
    def set_name(self, value):
        self._name = value

    def say(self):
        print(f"{self._name} says: Woof!")

class Cat:
    def __init__(self, name):
        self._name = name

    def get_name(self):
        return self._name

    def set_name(self, value):
        self._name = value

    def say(self):
        print(f"{self._name} says: Meow!")

## This block will run only when the script is executed directly.
if __name__ == "__main__":
    ## Create an instance of the Dog class
    my_dog = Dog("Buddy")
    print(f"Initial dog name: {my_dog.get_name()}")

    ## Change the dog's name using the setter method
    my_dog.set_name("Rocky")
    print(f"New dog name: {my_dog.get_name()}")
    my_dog.say()

    print("-" * 20)

    ## Create an instance of the Cat class
    my_cat = Cat("Whiskers")
    print(f"Cat name: {my_cat.get_name()}")
    my_cat.say()

After adding the code, save the file.

Now, let's run the script to see encapsulation in action. Open the terminal in the WebIDE and execute the following command:

python animal_classes.py

You will see the following output, which shows that we are interacting with the private _name attribute through our public get_name and set_name methods.

Initial dog name: Buddy
New dog name: Rocky
Rocky says: Woof!
--------------------
Cat name: Whiskers
Whiskers says: Meow!

Implement Inheritance and Polymorphism

In the previous step, you might have noticed that the Dog and Cat classes share a lot of identical code (__init__, get_name, set_name). This is a perfect opportunity to use inheritance. Inheritance allows a new class (the child or subclass) to inherit attributes and methods from an existing class (the parent or superclass), promoting code reuse.

We will also introduce polymorphism, which means "many forms". In OOP, it refers to the ability of different classes to respond to the same method call in their own unique ways.

Let's refactor our code. We will create a parent class Animal to hold the common code and have Dog and Cat inherit from it. The say method, which is different for each, will demonstrate polymorphism.

Open the animal_classes.py file and replace its entire content with the following code:

## File: animal_classes.py

class Animal:
    def __init__(self, name):
        self._name = name

    def get_name(self):
        return self._name

    def set_name(self, value):
        self._name = value

    def say(self):
        print(f"{self._name} makes a generic animal sound.")

## Dog inherits from Animal
class Dog(Animal):
    ## This overrides the say() method from the Animal class
    def say(self):
        print(f"{self._name} says: Woof!")

## Cat inherits from Animal
class Cat(Animal):
    ## This also overrides the say() method
    def say(self):
        print(f"{self._name} says: Meow!")

def make_animal_speak(animal_instance):
    animal_instance.say()

if __name__ == "__main__":
    generic_animal = Animal("Creature")
    my_dog = Dog("Buddy")
    my_cat = Cat("Whiskers")

    print("--- Calling say() on each object ---")
    generic_animal.say()
    my_dog.say()
    my_cat.say()

    print("\n--- Demonstrating Polymorphism ---")
    make_animal_speak(generic_animal)
    make_animal_speak(my_dog)
    make_animal_speak(my_cat)

Save the file. Notice how the Dog and Cat classes are now much simpler. They inherit the __init__, get_name, and set_name methods from Animal. They each provide their own version of the say method, which is an example of method overriding.

Now, run the updated script from the terminal:

python animal_classes.py

The output will be:

--- Calling say() on each object ---
Creature makes a generic animal sound.
Buddy says: Woof!
Whiskers says: Meow!

--- Demonstrating Polymorphism ---
Creature makes a generic animal sound.
Buddy says: Woof!
Whiskers says: Meow!

The make_animal_speak function takes any object that has a say method. Even though we pass it different types of objects (Animal, Dog, Cat), it works correctly because each object knows how to perform the say action in its own way. This is the power of polymorphism.

Utilize the super() Method to Extend Functionality

When a child class overrides a method from its parent, it sometimes needs to extend the parent's method, not just replace it. The super() function provides a way to call the parent class's method from within the child class.

This is very common in the __init__ method. A child class often needs to perform its own initialization steps in addition to the initialization performed by its parent.

Let's add unique attributes to our Dog and Cat classes. The Dog will have an age, and the Cat will have a color. We will use super() to ensure the parent Animal class's __init__ method is still called to set the _name.

Modify the animal_classes.py file by replacing its content with the following code:

## File: animal_classes.py

class Animal:
    def __init__(self, name):
        print(f"Animal __init__ called for {name}")
        self._name = name

    def get_name(self):
        return self._name

    def set_name(self, value):
        self._name = value

    def say(self):
        print(f"{self._name} makes a generic animal sound.")

class Dog(Animal):
    def __init__(self, name, age):
        ## Call the parent's __init__ method to handle the 'name' attribute
        super().__init__(name)
        print("Dog __init__ called")
        self.age = age

    def say(self):
        ## We can also use super() to call the parent's say() method
        ## super().say()
        print(f"{self._name} says: Woof! I am {self.age} years old.")

class Cat(Animal):
    def __init__(self, name, color):
        ## Call the parent's __init__ method
        super().__init__(name)
        print("Cat __init__ called")
        self.color = color

    def say(self):
        print(f"{self._name} says: Meow! I have {self.color} fur.")

if __name__ == "__main__":
    my_dog = Dog("Buddy", 5)
    my_dog.say()

    print("-" * 20)

    my_cat = Cat("Whiskers", "black")
    my_cat.say()

Save the file. In this version, Dog.__init__ and Cat.__init__ first call super().__init__(name). This executes the code in Animal.__init__, which sets the _name attribute. After that, they proceed with their own specific initializations (self.age = age and self.color = color).

Run the script from the terminal:

python animal_classes.py

The output demonstrates the chain of __init__ calls and the extended say methods:

Animal __init__ called for Buddy
Dog __init__ called
Buddy says: Woof! I am 5 years old.
--------------------
Animal __init__ called for Whiskers
Cat __init__ called
Whiskers says: Meow! I have black fur.

Practice Multiple Inheritance

Python allows a class to inherit from more than one parent class. This is called multiple inheritance. It can be a powerful tool for mixing functionalities from different sources, but it also introduces complexity, particularly in how Python decides which parent's method to use if they have the same name.

This search order is called the Method Resolution Order (MRO). Python uses an algorithm called C3 linearization to determine a consistent and predictable MRO.

Let's explore this with a new example. Open the file multiple_inheritance.py from the file explorer and add the following code:

## File: multiple_inheritance.py

class ParentA:
    def speak(self):
        print("Speaking from ParentA")

    def common_method(self):
        print("ParentA's common method")

class ParentB:
    def speak(self):
        print("Speaking from ParentB")

    def common_method(self):
        print("ParentB's common method")

## Child inherits from A, then B
class Child_AB(ParentA, ParentB):
    pass

## Child inherits from B, then A
class Child_BA(ParentB, ParentA):
    def common_method(self):
        print("Child_BA's own common method")

if __name__ == "__main__":
    child1 = Child_AB()
    child2 = Child_BA()

    print("--- Investigating Child_AB (ParentA, ParentB) ---")
    child1.speak()
    child1.common_method()
    ## The .mro() method shows the Method Resolution Order
    print("MRO for Child_AB:", [c.__name__ for c in Child_AB.mro()])

    print("\n--- Investigating Child_BA (ParentB, ParentA) ---")
    child2.speak()
    child2.common_method()
    print("MRO for Child_BA:", [c.__name__ for c in Child_BA.mro()])

Save the file. Here, Child_AB inherits from ParentA and then ParentB. Child_BA inherits in the reverse order. When a method is called, Python searches for it in the order specified by the MRO.

Run the script from the terminal:

python multiple_inheritance.py

You will see the following output:

--- Investigating Child_AB (ParentA, ParentB) ---
Speaking from ParentA
ParentA's common method
MRO for Child_AB: ['Child_AB', 'ParentA', 'ParentB', 'object']

--- Investigating Child_BA (ParentB, ParentA) ---
Speaking from ParentB
Child_BA's own common method
MRO for Child_BA: ['Child_BA', 'ParentB', 'ParentA', 'object']

From the output, you can observe:

  • child1.speak() calls the method from ParentA because ParentA comes first in Child_AB's MRO.
  • child2.speak() calls the method from ParentB because ParentB comes first in Child_BA's MRO.
  • child2.common_method() calls the version defined directly in Child_BA, as Python finds it there first before checking the parents.

Understanding the MRO is crucial for predicting behavior in multiple inheritance scenarios.

Summary

In this lab, you have gained hands-on experience with four fundamental concepts of object-oriented programming in Python.

You started with encapsulation, learning to protect class data by convention using private attributes and providing public methods for access. You then refactored your code to use inheritance, creating a parent Animal class to reduce code duplication in the Dog and Cat subclasses.

While implementing inheritance, you saw polymorphism in action, as Dog and Cat objects responded differently to the same say() method call. You learned to use the super() method to call and extend functionality from a parent class, particularly within the __init__ method. Finally, you explored multiple inheritance and the importance of the Method Resolution Order (MRO) in determining which parent method gets called.