Python Intermediate: Classes, objects, and inheritance

Building upon these foundational concepts, classes, and objects allow us to structure our code in a more organized and modular manner. They promote code reusability and help model real-world entities in a way that's intuitive and maintainable.

Python Intermediate: Classes, objects, and inheritance

For more posts about Python, follow the link below.👇👇👇

Exploring Python
Python is frequently utilized in creating websites and software, as well as for automating tasks, analyzing data, and visualizing information.

As a project evolves, its codebase keeps growing and gets more and more complex. Therefore to deal with large codebases we keep on seeking ways to structure code so it could be easily extended and maintained. One way of structuring a code is to decompose it into a series of computational steps called procedures or functions to be carried out. This programming paradigm is called procedural programming. The major differentiator of this paradigm is that behavior (function) is separated from data (property or attribute). Therefore at some point, someone came up with another idea, this time to merge behavior and data into a single entity called an object. The idea is further known as object-oriented programming.

Object-oriented programming

Object-Oriented Programming (OOP) is a programming paradigm that organizes code into objects and classes. Objects are instances of classes. OOP aims to model real-world entities and their interactions in software by representing them as objects with attributes (data) and methods (functions) that operate on that data.

The key principles and concepts of Object-Oriented Programming:

  1. Objects: Objects are instances of classes. They encapsulate both data (attributes or properties) and behaviors (methods or functions) related to a specific entity or concept.
  2. Classes: Classes are blueprints for creating objects. They define the structure and behavior that objects of that class will have. A class is a user-defined data type.
  3. Encapsulation: Encapsulation is the practice of bundling data (attributes) and methods that operate on that data into a single unit (an object). It hides the internal details of an object and restricts direct access to its data.
  4. Inheritance: Inheritance allows us to create a new class (subclass or derived class) based on an existing class (base class or superclass). The subclass inherits the attributes and methods of the superclass and can add or override them as needed.
  5. Polymorphism: Polymorphism enables objects of different classes to be treated as objects of a common base class. It allows for flexibility in the use of objects by using methods with the same name but different implementations in different classes.
  6. Abstraction: Abstraction involves simplifying complex systems by modeling classes based on their essential characteristics and ignoring unnecessary details. It allows developers to focus on what an object does rather than how it does it.
  7. Modularity: OOP promotes the creation of modular and reusable code. Classes can be developed and tested independently, making it easier to maintain and extend software systems.
  8. Message Passing: Objects in OOP communicate by sending messages to each other. A method call on an object is a message sent to that object, instructing it to perform a specific action.

Classes and objects

To create a class in Python, we use the class keyword followed by the class name. Class names are typically written in camel case, i.e. CamelCase. Here’s a simple class definition:

class Car:
    def drive(self):
        print(f'Driving at 40 km/h')

In the above example, we have defined a class named Car with a drive() method. Now we can instantiate it. To do it, we simply call it by its name.

c = Car()

In the above example, we have created an object of the Car class and assigned it to the variable c. So far, the Car has a drive() method that represents a behavior. Let’s add some properties now.

class Car:
    def __init__(self, make: str, speed: int):
        self.make: str = make
        self.speed: int = speed

    def drive(self):
        print(f'Driving {self.make} at {self.speed} km/h')

We have added two properties: make and speed. For proper object initialization, we have defined __init__(), a special method known as a constructor. Now drive() prints a message using data stored in the make and the speed members. Both methods use the self keyword that refers to the instance of the class and is used to access instance variables.

Instantiating an object of the Car class differs a bit from the previous example as we have to provide additional parameters now.

car1 = Car(f'Fiat', 50)
car2 = Car(f'Audi', 140)

In the above example, we have created two objects of the Car class. One has been constructed from the string Fiat and number 50, assigned to the car1 variable, and the other one has been constructed from the string Audi and number 140, assigned to the car2 variable. The number of parameters needed to instantiate the class corresponds to the number of parameters of the __init__() method excluding self.

Once we instantiate a class we can access its members - attributes and methods - using dot notation.

car1.drive()
car2.drive()
print(car1.make)
print(car2.make)

First, we call the drive() method on car1 and car2 objects, and then we print make attributes. Executing the above code gives the following output.

❯ python3 main.py
Driving Fiat at 50 km/h
Driving Audi at 140 km/h
Fiat
Audi

The make and speed are examples of instance variables that are unique to each class instance. On the other hand, there are class variables that are common to all instances of a class.

Let's introduce a default speed of 50 km/h and assign it to the class variable.

class Car:
    DEFAULT_SPEED: int = 50
  
    def __init__(self, make: str, speed: int = DEFAULT_SPEED):
        self.make: str = make
        self.speed: int = speed

    def drive(self):
        print(f'Driving {self.make} at {self.speed} km/h')

We will use it as a default value for the speed parameter. We can use it without class instantiation.

if __name__ == '__main__':
    car = Car(f'Fiat', Car.DEFAULT_SPEED)
    are_the_same: bool = id(Car.DEFAULT_SPEED) == id(car.DEFAULT_SPEED)
    print(f'Are Car.ID and car.ID the same objects? {are_the_same}')

It's worth mentioning that the DEFAULT_SPEED variable can be accessed via class as well as via object.

❯ python3 main.py
Are Car.ID and car.ID the same objects? True

Notice that both Car.DEFAULT_SPEED and car.DEFAULT_SPEED are the same objects in fact.

Encapsulation

Python does not enforce strict access control like “protected” or “private” access modifiers. However, Python uses naming conventions to signal the intended visibility of class members.

  • By default, all class members are considered public and can be accessed from anywhere.
  • A single underscore prefix (e.g., _variable or _method()) indicates that a class member should be treated as protected. This is more of a convention to suggest that the member is intended for internal use within the class and its subclasses. It’s not enforced, and you can still access protected members from outside the class.
  • A double underscore prefix (e.g., __variable or __method()) indicates that a class member should be treated as private. The double underscore prefix invokes name mangling, making it challenging to access these members from outside the class. However, it’s still possible to access them if you really need to.

Since we don't want to expose the implementation details of the Car class, we want to hide the make and the speed properties away from being accessible by the user of the class.

class Car:
    DEFAULT_SPEED: int = 50
  
    def __init__(self, make: str, speed: int = DEFAULT_SPEED):
        self.__make: str = make
        self.__speed: int = speed

    def drive(self):
        print(f'Driving {self.__make} at {self.__speed} km/h')

Now let's see what happens when we try to access restricted members.

car = Car(f'Audi', 140)
car.drive()
print(car.__make)

We create an object of the Car class, call a method, and then try to access a private member.

❯ python3 main.py
Driving Audi at 140 km/h
Traceback (most recent call last):
  File "main.py", line 12, in <module>
    print(car.__make)
          ^^^^^^^^^^^
AttributeError: 'Car' object has no attribute '__make'

We are no longer able to access a class attribute!

Inheritance

Imagine we have a class with some kind of functionality we would like to extend. Coping a code is probably not the best way to go, but we need a class with attributes and methods base class has. With help comes the inheritance, which allows us to create a new class based on an existing one.

Let's create an Ambulance class.

The ambulance class is enhanced to make a "wee woo" sound.

class Ambulance(Car):
    def siren(self):
        print(f'Wee woo!!!') 

To inherit from the Car class we have to specify its name in brackets right next to the name of the class being defined, i.e. Ambulance(Car).

ambulance = Ambulance(f'Porsche', 240)
ambulance.drive()
ambulance.siren()

Now we can instantiate an Ambulance object and try out its methods.

❯ python3 main.py
Driving Porsche at 240 km/h
Wee woo!!!

Works like a charm! Now let's extend the Ambulance class to substitute siren sound.

class Ambulance(Car):
    def __init__(self, make: str, speed: int = Car.DEFAULT_SPEED,  sound: str = f'Wee woo!!!'):
        super().__init__(make, speed)
        self.__sound: str = sound

    def siren(self):
        print(f'Siren: {self.__sound}')

We have added a constructor and provided a new parameter named sound to change the siren sound. Notice that inside the constructor, we call the constructor of the parent class. That is required for proper base class initialization.

ambulance = Ambulance(f'Porsche', 240, f'Nee-naw, nee-naw, nee-naw, nee-naw')
ambulance.drive()
ambulance.siren()

Now we can substitute the sound and run the script.

❯ python3 main.py
Driving Porsche at 240 km/h
Siren: Nee-naw, nee-naw, nee-naw, nee-naw

Now we have "Nee-naw, nee-naw, nee-naw, nee-naw" sound.

Polymorphism

This class hierarchy and inheritance open the gates of polymorphism which allows objects of different classes to be treated as objects of a common base class.

The Ambulance is also a Car, so it can be treated in that way. Let's define a common function to drive all cars.

def drive(cars: list[Car]):
    for c in cars:
        c.drive()

The drive() function takes a list of Cars and then for each car, a drive() method is called. Now we can pass a car and an ambulance into the function.

car = Car(f'Audi', 140)
ambulance = Ambulance(f'Porsche', 240, f'Nee-naw, nee-naw, nee-naw, nee-naw')
ambulance.siren()
drive([car, ambulance])

Basically, we have created a list of two elements, a car, and an ambulance, and passed them into the drive() function. Polymorphism gives us the possibility to interact with different objects having different implementations, but having the same, common interface.

❯ python3 main.py
Siren: Nee-naw, nee-naw, nee-naw, nee-naw
Driving Audi at 140 km/h
Driving Porsche at 240 km/h

Summary


Building upon these foundational concepts, classes, and objects allow us to structure our code in a more organized and modular manner. They promote code reusability and help model real-world entities in a way that's intuitive and maintainable. Inheritance takes this a step further, allowing us to create specialized classes that inherit common attributes and behaviors from a base class. This not only streamlines code development but also establishes clear hierarchies in a software design.

Additionally, polymorphism enhances the flexibility of our code by enabling objects of different classes to be treated uniformly when they share common behaviors or interfaces. This means we can write more generic and versatile code that can work seamlessly with a variety of objects. These core concepts are not only integral to Python but also to object-oriented programming in general, making them essential for any programmer looking to build efficient and scalable software solutions.

What’s next?

Subscribe to receive more posts like this directly to your email!

Python Intermediate: Dunder methods
“Dunder” is short for “double underscore,” and it is commonly used in the context of Python programming.

Ideas💡, questions❔❓ ? Feel free to start the discussion 🗣️🎙️!

Ready for challenges? 👇👇👇

Python Intermediate: Brain teasers
In the world of programming, the road to expertise is paved with challenges. Don’t shy away from solving programming problems; instead, embrace them as opportunities for growth.