Object-Oriented Programming (OOP) in Python 3

Object-Oriented Programming (OOP) in Python 3

Introduction

Object-oriented programming (OOP) is a method of organizing a program by grouping related properties and behaviors into separate objects. In this tutorial, you will learn the fundamentals of object-oriented programming in Python.

Objects function conceptually similarly to system components. Consider a program to be similar to a factory assembly line. At each stage of the assembly line, a system component processes some material, eventually transforming raw material into finished goods.

An object contains data, such as raw or preprocessed materials at each stage of an assembly line. In addition, the object contains behavior, such as the action that each assembly line component takes.

What Is Python Object-Oriented Programming?

A programming paradigm known as object-oriented programming offers a way to organize programs so that various behaviors and properties are combined into single objects.

For example, an object could represent a person by having properties such as a name, age, and address, as well as behaviors like walking, talking, breathing, and running. It could also represent an email with properties such as a recipient list, subject, and body, as well as actions such as attaching files and sending.

To put it another way, object-oriented programming is a method for modeling concrete, real-world things like cars, as well as relationships between things like businesses and employees or students and teachers. OOP represents real-world entities as software objects with associated data and the ability to perform specific operations.

The key takeaway is that objects are central to Python’s object-oriented programming paradigm. Other programming paradigms use objects to represent data. In OOP, they also influence the overall structure of the program.

How Do You Define a Class in Python?

In Python, you define a class by typing the class keyword, followed by a name and a colon. Then, you use.init() to declare what attributes each instance of the class should have:

class Student:
    def __init__(self, name, age):
        self.student_name=  name
        self.student_age = age

This code defines a class called Student that has two attributes: student_name and student_age.

Key Points

  • Class Definition: class Student: starts the definition of the Student class.
  • Constructor Method: def __init__(self, name, age): is a special method called the constructor. It is automatically called when you create a new instance of the class.
  • Attributes: self.student_name and self.student_age are the attributes of the class. self refers to the instance of the class being created. name and age are parameters that you provide when creating a new Student object.

Example Usage

student1 = Student("Alice", 20)
print(student1.student_name)  # Output: Alice
print(student1.student_age)   # Output: 20

In this example, student1 is an instance of the Student class with student_name set to “Alice” and student_age set to 20.

Why Class Used

But what does all of this mean? And why would you need classes in the first place? Take a step back and consider using built-in primitive data structures instead.

Primitive data structures—such as numbers, strings, and lists—are intended to represent simple pieces of information, such as the price of an apple, the title of a poem, or your favorite colors. What if you want to depict something more complex?

For example, you may want to keep track of Students in an collage. You should keep some basic information about each students , such as their name, age, branch, and the year they began admission.

One way to accomplish this is to represent each students as a list.

science= ["gp", 17, "science", 2265]
commerce= ["jna", 18, "Commerce", 2254]
arts= ["pm", "Arts", 2266]

There are several issues with this approach.

First, larger code files may become more difficult to manage. Will you remember the students name if you refer to science[0] several lines after declaring the science list?

Second, errors can occur if students’ lists do not contain the same number of elements. The age is missing from the arts list above, so arts[1] returns “Arts” rather than pm age.

Classes are an excellent way to make this type of code easier to manage and maintain.

Classes vs Instances

Classes enable you to create user-defined data structures. Classes define functions known as methods, which specify the behaviors and actions that an object derived from the class can perform with its data.

In this tutorial, you’ll create a Dog class that contains information about the characteristics and behaviors that a specific dog can exhibit.

A class is a template for how to define something. It does not actually contain any data. The Dog class states that a name and age are required for defining a dog, but it does not include the name or age of any particular dog.

The class is the blueprint, whereas an instance is an object created from a class that contains real data. An instance of the Dog class is no longer a blueprint. It is a real dog with a name, such as Miles, who is four years old.

In other words, a class functions similarly to a form or questionnaire. An instance is similar to a form that you’ve filled out with information. Just as many people can fill out the same form with their own unique information, you can create multiple instances from a single class.

Class Definition

You start all class definitions with the class keyword, then add the name of the class and a colon. Python will consider any code that you indent below the class definition as part of the class’s body.

Here’s an example of a Car class:

# car.py

class Car:
    pass

The Car class’s body contains only one statement: the pass keyword. Python programmers frequently use pass as a placeholder for where the code will eventually go. It allows you to run this code without encountering a Python error.

Note: Python class names are written in CapitalizedWords notation by convention. For example, a class for a specific type of car, like the Tesla Electric Car, would be written as TeslaElectricCar.

The Car class isn’t particularly interesting right now, so you’ll spice it up by specifying some properties that all Car objects should have. There are several properties to choose from, including name, model, year and color. To keep the example simple, you will only use name and model.

You define the properties that all Car objects must have in a method called init(). Every time you create a new Car object init() determines the object’s initial state by assigning values to its properties. That is init() initializes each new instance of the class.

You can pass any number of parameters to init(), but the first parameter is always a variable named self. When you create a new class instance, Python automatically passes it to the self parameter in init(), allowing Python to define the object’s new attributes.

Update the Car class with a init() method that generates the.name and Model attributes:

# car.py

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

Make sure to indent the init() method signature by four spaces and the method body by eight spaces. This indentation is extremely important. It informs Python that the init() method belongs to the Car class.

In the body of init(), there are two statements that use the self variable:

  1. self.name = name creates an attribute called name and assigns the value of the name parameter to it.
  2. self.model = model creates an attribute called model and assigns the value of the model parameter to it.

Attributes created in init() are known as instance attributes. The value of an instance attribute is unique to each class instance. All Car objects have a name and an model, but the values of the name and model attributes differ depending on the Car instance.

Class attributes, on the other hand, have the same value across all class instances. To define a class attribute, assign a value to a variable name outside of init().

For example, the following Car class has a class attribute called vehicle_type with the value "Automobile":

class Car:
    vehicle_type = "Automobile"

    def __init__(self, name, model):
        self.name= name
        self.model = model

You define class attributes directly beneath the first line of the class name, indenting them by four spaces. Always give them an initial value. When you create an instance of the class, Python automatically generates and assigns class attributes to their default values.

Class attributes are used to define properties that should be the same for all class instances. Use instance attributes for properties that differ between instances.

Now that you have a Car class, it’s time to make some cars!

In Python, how do you instantiate a class??

Instantiating a class is the process of generating a new object from a class. Typing the class name, followed by the opening and closing parenthesis, will create a new object:

>>> class Car:
...     pass
...
>>> Car()
<__main__.Car object at 0x106705d90>

You first define a new Car class with no attributes or methods, and then you instantiate it to get a Car object.

The output above shows that you now have a new Car object at 0x106702d30. This strange-looking string of letters and numbers is a memory address, indicating where Python stores the Car object in your computer’s memory. Take note that the address on your screen will differ.

Now, instantiate the Car class again to create another Car object:

>>> Car()
<__main__.Car object at 0x0304cac90>

The new Car instance resides at a different memory address. That’s because it’s a completely new instance that is distinct from the first Car object you created.

To see it another way, type the following:

>>> a = Car()
>>> b = Car()
>>> a == b
False

This code creates two new Car objects and assigns them to variables a and b. When you compare a and b with the == operator, the result is False. Even though a and b both belong to the Car class, they represent two distinct objects in memory.

Class and Instance Attributes

Now, create a new class called Car and give it two instance attributes .name and .year and a class attribute called .vehicle_type :

>>> class Car:
...     vehicle_type = "Automobile"
...     def __init__(self, name, year):
...         self.name = name
...         self.year = year
...

To instantiate this Car class, you need to provide values for name and year. If you don’t, then Python raises a TypeError:

>>> Car()
Traceback (most recent call last):
  ...
TypeError: __init__() missing 2 required positional arguments: 'name' and 'year'

To pass arguments to the name and year parameters, put values into the parentheses after the class name:

>>> bmw = Car("BMW", 2024)
>>> ford = Dog("Ford", 2023)

This generates two new Car instances: one for a car named BMW from 2024 and another for a car named Ford from 2023.

Why are you only passing two arguments to the.init() method of the Car class in the example when it has three parameters?

Python builds a new instance of the Car class and passes it to the first parameter of.init() when you instantiate the Car class. By doing this, the self parameter is effectively eliminated, leaving the name and year parameters to be your only concerns.

After you create the Car instances, you can access their instance attributes using dot notation:

>>> bmw.name
'BMW'
>>> bmw.year
2024

>>> ford.name
'Ford'
>>> ford.year
2023

You can access class attributes the same way:

>>> ford.vehicle_type
'Automobile'

The fact that instances of classes are guaranteed to have the attributes you expect is one of the main benefits of using classes to organize data. The .vehicle_type, .name, and .year attributes are present in every Car instance, so you can use them with confidence since you know they’ll always return a value.

The attributes are certain to exist, but their values are subject to dynamic change:

>>> ford.year = 2025
>>> ford.year
2025

>>> ford.vehicle_type = "Electric"
>>> ford.vehicle_type
'Electric'

In this example, you change the .year attribute of the ford object to 2025. Then you change the .vehicle_type attribute of the ford object to "Electric", which is a vehicle_type of Cars. That makes ford a pretty strange Car, but it’s valid Python!

The key takeaway here is that custom objects are mutable by default. An object is mutable if you can alter it dynamically. For example, lists and dictionaries are mutable, but strings and tuples are immutable.

Instance Methods

Functions that you define inside a class that are limited to calling on instances of that class are known as instance methods. An instance method always accepts self as its first parameter, just like .init().

In IDLE, open a new editor window and enter the Car class:

# car.py

class Car:
    vehicle_type = "Automobile"

    def __init__(self, name, year):
        self.name = name
        self.year = year

    # Instance method
    def description(self):
        return f"{self.name} is {self.year} years old"

    # Another instance method
    def car_types(self, car_type):
        return f"{self.name} car type is {car_type}"

This Car class has two instance methods:

  1. .description() returns a string displaying the name and age of the dog.
  2. .car_types() has one parameter called car_type and returns a string containing the car’s name and the car_type.

Save the modified Car class to a file called car.py and press F5 to run the program. Then open the interactive window and type the following to see your instance methods in action:

>>> cars = Car("Ford", 4)

>>> cars.description()
'Ford is 4 years old'

>>> cars.car_types("coupe")
'Ford car type is coupe'

In the above Car class, .description() returns a string containing information about the Car instance cars. When writing your own classes, it’s a good idea to have a method that returns a string containing useful information about an instance of the class. However, .description() isn’t the most Pythonic way of doing this.

When you create a list object, you can use print() to display a string that looks like the list:

>>> names = ["gp", "jna", "pm"]
>>> print(names)
["gp", "jna", "pm"]

Go ahead and print the cars object to see what output you get:

>>> print(cars)
<__main__.Car object at 5x005eff74>

When you print cars, you get a cryptic-looking message telling you that cars is a Car object at the memory address 5x005eff74. This message isn’t very helpful. You can change what gets printed by defining a special instance method called .__str__().

In the editor window, change the name of the Car class’s .description() method to .__str__():

# car.py

class Car:
    # ...

    def __str__(self):
        return f"{self.name} is {self.year} years old"

Save the file and press F5. Now, when you print cars, you get a much friendlier output:

>>> cars = Car("Ford", 4)
>>> print(cars)
'Ford is 4 years old'

Methods like .__init__() and .__str__() are called dunder methods because they begin and end with double underscores. There are many dunder methods that you can use to customize classes in Python. Understanding dunder methods is an important part of mastering object-oriented programming in Python, but for your first exploration of the topic, you’ll stick with these two dunder methods.

How Do You Inherit From Another Class in Python?

The process by which one class inherits the characteristics and operations of another is known as inheritance. Classes that have just formed are referred to as child classes, and classes from which child classes are derived are referred to as parent classes.

By making a new class and enclosing the parent class name in parenthesis, you can inherit from an existing class:

# inheritance.py

class Parent:
    hair_color = "brown"

class Child(Parent):
    pass

In this simple example, the child class Child derives from the parent class Parent. Because child classes inherit parent classes’ attributes and methods, Child.hair_color is also “brown” unless you explicitly specify otherwise.

Child classes can override or extend the attributes and methods of their parent classes. In other words, child classes inherit all of their parent’s attributes and methods while also allowing them to specify attributes and methods that are unique to them.

Although the analogy is not perfect, you can think of object inheritance as similar to genetic inheritance.

You may have inherited your hair color from your parents. It is a characteristic that you were born with. But maybe you decide to dye your hair purple. Assuming that your parents do not have purple hair, you have simply overridden the hair color attribute that you inherited from your parents:

# inheritance.py

class Parent:
    hair_color = "brown"

class Child(Parent):
    hair_color = "purple"

If you change the code example like this, Child.hair_color will become “purple”.

In some ways, you inherit your parents’ language as well. If your parents speak English, you will also speak it. Now imagine you decide to learn a second language, such as German. In this case, you’ve extended your attributes by adding an attribute that your parents don’t have.

# inheritance.py

class Parent:
    speaks = ["English"]

class Child(Parent):
    def __init__(self):
        super().__init__()
        self.speaks.append("German")

The sections that follow will explain how the code above works. However, before delving deeper into inheritance in Python, you’ll go for a walk to a Car Example to better understand why you might want to use inheritance in your code.

Imagine you are at a car showroom where there are many cars of different models, each with various characteristics.

Suppose you want to model the car showroom with Python classes. The Car class that you wrote in the previous section can distinguish cars by brand and year but not by model.

You could modify the Car class in the editor window by adding a .model attribute:

# car.py

class Car:
    type = "Automobile"

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

    def __str__(self):
        return f"{self.brand} {self.model} ({self.year})"

    def start_engine(self, sound):
        return f"The engine of the {self.brand} {self.model} goes {sound}"

Press F5 to save the file. Now you can model the car showroom by creating different cars in the interactive window:

>>> tesla = Car("Tesla", 2022, "Model S")
>>> bmw = Car("BMW", 2020, "X5")
>>> audi = Car("Audi", 2021, "A6")
>>> ford = Car("Ford", 2019, "Mustang")

Each model of car might have a distinct engine sound. For example, the Tesla might have a silent start, while the Mustang has a roaring engine sound.

Using just the Car class, you must supply a string for the sound argument of .start_engine() every time you call it on a Car instance:

>>> tesla.start_engine("...") # Tesla is electric
'The engine of the Tesla Model S goes ...'

>>> bmw.start_engine("Vroom")
'The engine of the BMW X5 goes Vroom'

>>> audi.start_engine("Vroom")
'The engine of the Audi A6 goes Vroom'

>>> ford.start_engine("Roar")
'The engine of the Ford Mustang goes Roar'

Passing a string to every call to .start_engine() is repetitive and inconvenient. Moreover, the .model attribute should determine the string representing the sound that each Car instance makes, but here you have to manually pass the correct string to .start_engine() every time you call it.

You can simplify the experience of working with the Car class by creating a child class for each model of car. This allows you to extend the functionality that each child class inherits, including specifying a default argument for .start_engine():

# car_models.py

class Tesla(Car):
    def start_engine(self, sound="..."):
        return super().start_engine(sound)

class BMW(Car):
    def start_engine(self, sound="Vroom"):
        return super().start_engine(sound)

class Audi(Car):
    def start_engine(self, sound="Vroom"):
        return super().start_engine(sound)

class Ford(Car):
    def start_engine(self, sound="Roar"):
        return super().start_engine(sound)

Now, you can create instances of these child classes without needing to specify the engine sound each time:

>>> tesla = Tesla("Tesla", 2022, "Model S")
>>> bmw = BMW("BMW", 2020, "X5")
>>> audi = Audi("Audi", 2021, "A6")
>>> ford = Ford("Ford", 2019, "Mustang")

>>> tesla.start_engine()
'The engine of the Tesla Model S goes ...'

>>> bmw.start_engine()
'The engine of the BMW X5 goes Vroom'

>>> audi.start_engine()
'The engine of the Audi A6 goes Vroom'

>>> ford.start_engine()
'The engine of the Ford Mustang goes Roar'

By using inheritance, you have simplified the way you work with different car models. Each child class extends the functionality of the parent Car class and provides default behavior specific to each car model, making your code more organized and easier to maintain.

Parent Classes vs Child Classes

In this section, you’ll create a child class for each of the three car models mentioned above: Tesla Model S, BMW X5, and Ford Mustang.

For reference, here’s the full definition of the Car class that you’re currently working with:

# car.py

class Car:
    type = "Automobile"

    def __init__(self, brand, year):
        self.brand = brand
        self.year = year

    def __str__(self):
        return f"{self.brand} ({self.year})"

    def start_engine(self, sound):
        return f"The engine of the {self.brand} goes {sound}"

After the previous car showroom example, you’ve removed .model again. Now you’ll write code to keep track of a car’s model using child classes instead.

To create a child class, you create a new class with its own name and then put the name of the parent class in parentheses. Add the following to the car.py file to create three new child classes of the Car class:

# car.py

# ...

class TeslaModelS(Car):
    pass

class BMWX5(Car):
    pass

class FordMustang(Car):
    pass

Press F5 to save and run the file. With the child classes defined, you can now create some cars of specific models in the interactive window:

>>> tesla = TeslaModelS("Tesla", 2022)
>>> bmw = BMWX5("BMW", 2020)
>>> ford = FordMustang("Ford", 2019)

Instances of child classes inherit all of the attributes and methods of the parent class:

>>> tesla.type
'Automobile'

>>> bmw.brand
'BMW'

>>> print(ford)
Ford (2019)

>>> tesla.start_engine("Silent")
'The engine of the Tesla goes Silent'

To determine which class a given object belongs to, you can use the built-in type():

>>> type(tesla)
<class '__main__.TeslaModelS'>

What if you want to determine if tesla is also an instance of the Car class? You can do this with the built-in isinstance():

>>> isinstance(tesla, Car)
True

Notice that isinstance() takes two arguments, an object and a class. In the example above, isinstance() checks if tesla is an instance of the Car class and returns True.

The tesla, bmw, and ford objects are all Car instances, but tesla isn’t a BMWX5 instance, and bmw isn’t a FordMustang instance:

>>> isinstance(tesla, BMWX5)
False

>>> isinstance(bmw, FordMustang)
False

More generally, all objects created from a child class are instances of the parent class, although they may not be instances of other child classes.

Now that you’ve created child classes for some different models of cars, you can give each model its own sound.

# car_models.py

class TeslaModelS(Car):
    def start_engine(self, sound="Silent"):
        return super().start_engine(sound)

class BMWX5(Car):
    def start_engine(self, sound="Vroom"):
        return super().start_engine(sound)

class FordMustang(Car):
    def start_engine(self, sound="Roar"):
        return super().start_engine(sound)

With these definitions, you can create instances of these child classes without needing to specify the engine sound each time:

>>> tesla = TeslaModelS("Tesla", 2022)
>>> bmw = BMWX5("BMW", 2020)
>>> ford = FordMustang("Ford", 2019)

>>> tesla.start_engine()
'The engine of the Tesla goes Silent'

>>> bmw.start_engine()
'The engine of the BMW goes Vroom'

>>> ford.start_engine()
'The engine of the Ford goes Roar'

By using inheritance, you have simplified the way you work with different car models. Each child class extends the functionality of the parent Car class and provides default behavior specific to each car model, making your code more organized and easier to maintain.

Core Pillars of OOP in Python

Encapsulation: This principle involves hiding the internal state of an object and requiring all interaction to be performed through an object’s methods. It improves modularity and prevents external access to an object’s internal representation.

Inheritance: Python supports inheritance, allowing one class to inherit attributes and methods from another. It promotes code reuse and the creation of a hierarchical organization of classes.

Polymorphism: Polymorphism allows methods to do different things based on the object it is acting upon, even though they share the same name. This is achieved through method overriding.

Abstraction: Abstraction means hiding complex implementation details and showing only the essential features of the object. In Python, this is often achieved through abstract classes and methods.

Advanced OOP Concepts in Python

Composition vs Inheritance: While inheritance represents an “is-a” relationship, composition encapsulates a “has-a” relationship. Choosing composition over inheritance can lead to more flexible and maintainable code.

Magic Methods: Python classes have several special methods, known as magic methods, identified by double underscores (__), such as __init__, __str__, and __len__. These methods enable class instances to mimic built-in types and respond to built-in functions.

Decorators: Decorators in Python are a powerful feature that allows the modification of methods or functions without changing their code. They can be used to extend or alter the behavior of methods in a class.

Examples

Encapsulation

Encapsulation is about bundling the data (attributes) and code (methods) that operates on the data into a single unit, the class, and restricting access to some of the object’s components. This is usually done by making attributes private to hide them from the outside world.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance  # Private attribute to encapsulate balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"{amount} deposited. New balance is {self.__balance}.")
        
    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"{amount} withdrawn. Remaining balance is {self.__balance}.")
        else:
            print("Insufficient balance for the withdrawal.")

In this example, the balance of the bank account is encapsulated within the class, preventing direct access from outside. The methods deposit and withdraw provide controlled access to modify the balance.

Inheritance

Inheritance allows us to define a class that inherits all the methods and properties from another class. This is a powerful feature that facilitates code reusability.

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

    def speak(self):
        raise NotImplementedError("Subclass must implement abstract method")

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

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

dog = Dog("Buddy")
cat = Cat("Whiskers")

print(dog.speak())  # Output: Woof!
print(cat.speak())  # Output: Meow!

Here, Dog and Cat classes inherit from the Animal class. They each implement the speak method differently, showcasing polymorphism as well.

Polymorphism

Polymorphism allows us to define methods in the child class with the same name as defined in their parent class. It is the ability of different object types to be accessed through the same interface, with each type responding in a different way.

class Bird:
    def intro(self):
        print("There are many types of birds.")
      
    def flight(self):
        print("Most of the birds can fly but some cannot.")
    
class Sparrow(Bird):
    def flight(self):
        print("Sparrows can fly.")
      
class Ostrich(Bird):
    def flight(self):
        print("Ostriches cannot fly.")
      
bird = Bird()
sparrow = Sparrow()
ostrich = Ostrich()

bird.intro()
bird.flight()

sparrow.intro()
sparrow.flight()

ostrich.intro()
ostrich.flight()

Abstraction

Abstraction means hiding the complex reality while exposing only the necessary parts. In Python, abstraction is achieved by using abstract classes and methods that are declared in a class but must be implemented by an inherited class.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    @abstractmethod
    def perimeter(self):
        pass

class Rectangle(Shape):
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * (self.length + self.width)

# Usage
rectangle = Rectangle(10, 5)
print(f"Area: {rectangle.area()}")  # Output: Area: 50
print(f"Perimeter: {rectangle.perimeter()}")  # Output: Perimeter: 30

Shape is an abstract class that defines a contract for area and perimeter methods without implementing them. Rectangle implements these methods, providing specific functionalities.

Best Practices for OOP in Python

  • Utilize encapsulation to protect the internal state of objects.
  • Prefer composition over inheritance for more flexible code.
  • Use polymorphism to handle different types through a uniform interface.
  • Leverage abstract base classes to enforce class contracts.

Conclusion

OOP in Python is a powerful tool for writing efficient, scalable, and maintainable code. By understanding and applying the principles of OOP, developers can leverage Python’s flexibility to build robust applications.

FAQs Section

  • What is the difference between a class and an object in Python? A class is a blueprint for creating objects, while an object is an instance of a class.
  • How does encapsulation improve code in Python? Encapsulation improves code by hiding its internal state and requiring all interactions to go through an object’s methods, thereby reducing dependencies and increasing modularity.

Happy coding!

Let’s Get in Touch! Follow me on :

>GitHub: @gajanan0707

>LinkedIn: Gajanan Rajput

>Website: https://mrcoder701.com

>YouTube: mrcoder701

> Instagram: mr_coder_701

Show 1 Comment

1 Comment

Leave a Reply

Your email address will not be published. Required fields are marked *