Learn OOPS in Python: A Simple Guide for Newbies

Welcome to a beginner's guide to OOPS in Python! I'm Brahma 👋, a passionate software developer. I am documenting my learning journey through a series of blog posts. Stay tuned!!

Introduction

OOPS in any programming language, is kind of interesting and tricky, unlike it's English literal meaning😂. I know it was a bad joke, but let's dive into this new jargon.

So, broadly there are two types of programming language: Procedure Oriented Programming Language aka POP and Object Oriented Programming Language aka OOP.

POP basically follows the procedure / function method of programming i.e., we have different functions for different tasks.

OOP on the other hand operates on the class and object mode of operation i.e., we make classes for various use cases and object(s) of the class help to utilize the usability of the class.

Object-Oriented Programming (OOP) is a programming paradigm that uses objects and classes to structure software. Unlike procedural programming, which focuses on functions and procedures to operate on data, OOP organizes software design around data, or objects, and the functions that operate on them. This approach helps manage and scale complex software systems by promoting modularity, code reuse, and a clear structure.

In Python, OOP is a core concept and is widely used across various applications, from web development to data analysis. Python's simplicity and readability make it an ideal language for learning and implementing OOP principles. By mastering OOP in Python, developers can write more maintainable and scalable code, making it easier to manage large projects and collaborate with others.

In this article, we will explore the fundamental concepts of OOP in Python, including classes and objects, attributes and methods, and the four pillars of OOP: encapsulation, inheritance, polymorphism, and abstraction. By the end of this article, you'll have a solid understanding of how to apply OOP principles in your Python projects, enhancing your coding skills and efficiency.

Definitions

Classes and Objects

Classes: In OOP, a class is a blueprint for creating objects. It defines a set of attributes and methods that the objects created from the class can use. Think of a class as a template that defines the properties and behaviors of an object. For example, if you have a class Car, it might have attributes like color and make, and methods like drive() and stop().

Objects: Objects are instances of a class. When a class is defined, no memory is allocated until an object of that class is created. Each object has its own set of attributes and can perform the methods defined in the class. For instance, using the Car class, you can create multiple objects like my_car and your_car, each with its own specific attributes and behaviors.

Example:

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

    def drive(self):
        return f"The {self.make} {self.model} is driving."

my_car = Car("Toyota", "Corolla")
print(my_car.drive())  # Output: The Toyota Corolla is driving.

In this example, Car is a class, and my_car is an object created from the Car class.

Attributes and Methods

Attributes: Attributes are variables that belong to a class. They represent the state or data of an object. Attributes can be defined directly within the class and are usually initialized in the constructor method (__init__). There are two types of attributes: instance attributes and class attributes. Instance attributes are specific to an object, while class attributes are shared across all instances of the class.

Example:

class Dog:
    species = "Canis lupus familiaris"  # Class attribute

    def __init__(self, name, age):
        self.name = name  # Instance attribute
        self.age = age  # Instance attribute

my_dog = Dog("Buddy", 3)
print(my_dog.name)  # Output: Buddy
print(my_dog.species)  # Output: Canis lupus familiaris

In this example, name and age are instance attributes, while species is a class attribute.

Methods: Methods are functions defined within a class that describe the behaviors of an object. They can manipulate the object's attributes and provide functionality to the objects created from the class. Methods are called on objects, and they often use the self parameter to refer to the instance on which they are called.

Example:

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return f"{self.name} says woof!"

    def get_human_age(self):
        return self.age * 7

my_dog = Dog("Buddy", 3)
print(my_dog.bark())  # Output: Buddy says woof!
print(my_dog.get_human_age())  # Output: 21

In this example, bark and get_human_age are methods that define behaviors for the Dog class. They can access and manipulate the instance attributes name and age using the self keyword.

  1. Encapsulation

Encapsulation is the concept of bundling data (attributes) and methods (functions) that operate on the data into a single unit, or class. It restricts direct access to some of the object's components, which is a way of preventing accidental interference and misuse of the data. This is achieved using access modifiers (public, protected, private).

In Python, encapsulation is not enforced strictly, but naming conventions are used to indicate the intended level of access:

  • Public: Attributes and methods that are meant to be accessible from outside the class. They are declared normally.

  • Protected: Attributes and methods intended for use within the class and its subclasses. They are indicated by a single underscore _.

  • Private: Attributes and methods meant to be used only within the class. They are indicated by a double underscore __.

Example:

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

    def deposit(self, amount):
        self.__balance += amount  # Accessing private attribute within the class

    def get_balance(self):
        return self.__balance  # Accessing private attribute within the class

account = BankAccount(1000)
account.deposit(500)
print(account.get_balance())  # Output: 1500
# print(account.__balance)  # This would raise an AttributeError
  1. Inheritance

Inheritance is a mechanism in which one class (child or derived class) inherits the attributes and methods from another class (parent or base class). This allows for code reuse and the creation of a hierarchical relationship between classes. The derived class can also override or extend the functionality of the base class.

Example:

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

    def speak(self):
        return "Some sound"

class Dog(Animal):
    def speak(self):
        return f"{self.name} says woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow!"

my_dog = Dog("Buddy")
my_cat = Cat("Whiskers")
print(my_dog.speak())  # Output: Buddy says woof!
print(my_cat.speak())  # Output: Whiskers says meow!
  1. Polymorphism

Polymorphism allows methods to do different things based on the object it is acting upon, even though they share the same name. It provides a way to use a single interface to represent different data types. Polymorphism is achieved through method overriding (as shown in the inheritance example) and method overloading (not natively supported in Python but can be mimicked).

Example:

class Bird:
    def speak(self):
        return "chirp"

class Duck(Bird):
    def speak(self):
        return "quack"

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

duck = Duck()
bird = Bird()
animal_sound(duck)  # Output: quack
animal_sound(bird)  # Output: chirp
  1. Abstraction

Abstraction involves hiding the complex implementation details and showing only the necessary features of an object. It is achieved using abstract classes and interfaces. In Python, abstraction is typically implemented using abstract base classes (ABCs) from the abc module.

Example:

from abc import ABC, abstractmethod

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

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

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

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius * self.radius

rect = Rectangle(10, 20)
circle = Circle(5)
print(rect.area())  # Output: 200
print(circle.area())  # Output: 78.5

In this example, Shape is an abstract class with an abstract method area(), which must be implemented by any subclass, ensuring that all shapes will have a way to calculate their area.

By this point you already know much of the OOPs concept using Python. There are some unusually weird stuff which are good to know extras which I will be listing below:

Extra Stuff

Class Variables

Class Variables are variables that are shared among all instances of a class. They are defined within a class but outside any methods. Class variables are used to store data that should be common to all instances of the class, rather than instance-specific data. e.g.,

class Dog:
    species = "Canis lupus familiaris"  # Class variable

    def __init__(self, name, age):
        self.name = name  # Instance variable
        self.age = age  # Instance variable

# Accessing class variable
print(Dog.species)  # Output: Canis lupus familiaris

# Creating instances
dog1 = Dog("Buddy", 3)
dog2 = Dog("Milo", 2)

# Accessing instance variables
print(dog1.name)  # Output: Buddy
print(dog2.name)  # Output: Milo

# Accessing class variable through instances
print(dog1.species)  # Output: Canis lupus familiaris
print(dog2.species)  # Output: Canis lupus familiaris

In this example, species is a class variable shared by all instances of the Dog class. Changing the class variable will affect all instances.

Static Methods

Static Methods are methods that belong to a class but do not require an instance of the class to be called. They do not modify the state of an instance or the class and do not access any instance or class-specific data. Static methods are defined using the @staticmethod decorator. e.g.,

class MathOperations:
    @staticmethod
    def add(a, b):
        return a + b

    @staticmethod
    def subtract(a, b):
        return a - b

# Calling static methods
print(MathOperations.add(5, 3))  # Output: 8
print(MathOperations.subtract(5, 3))  # Output: 2

In this example, add and subtract are static methods of the MathOperations class. They can be called on the class itself without needing to create an instance of the class.

Usage of Class Variables and Static Methods:

  • Class Variables: Use class variables for data that is common across all instances of a class. This can be useful for defining constants or default values.

  • Static Methods: Use static methods for functions that logically belong to the class but do not require access to class or instance data. They are often used for utility or helper functions.

Multiple Inheritance (Advanced Stuff)

Multiple Inheritance is a feature of some object-oriented programming languages, including Python, that allows a class to inherit attributes and methods from more than one parent class. This can be useful for creating complex class hierarchies and for code reuse, but it also introduces potential complexity and ambiguity, such as the Diamond Problem.

Example of Multiple Inheritance

Basic Example:

class Parent1:
    def __init__(self):
        self.name = "Parent1"

    def method1(self):
        return "Method from Parent1"

class Parent2:
    def __init__(self):
        self.name = "Parent2"

    def method2(self):
        return "Method from Parent2"

class Child(Parent1, Parent2):
    def __init__(self):
        Parent1.__init__(self)
        Parent2.__init__(self)

    def combined_methods(self):
        return f"{self.method1()} and {self.method2()}"

# Creating an instance of Child
child = Child()
print(child.combined_methods())  # Output: Method from Parent1 and Method from Parent2

In this example, the Child class inherits from both Parent1 and Parent2. It can access methods from both parent classes. Note that both parent classes have an __init__ method, and the Child class explicitly calls these __init__ methods to ensure that the initialization from both parents is executed.

Diamond Problem

The Diamond Problem occurs when a class inherits from two classes that both inherit from a common superclass. This can create ambiguity in the inheritance chain.

Example:

class A:
    def method(self):
        return "Method from A"

class B(A):
    def method(self):
        return "Method from B"

class C(A):
    def method(self):
        return "Method from C"

class D(B, C):
    pass

d = D()
print(d.method())  # Output: Method from C

In this example, D inherits from both B and C, which both inherit from A. When D calls method, there is ambiguity about whether to use method from B or C. Python uses the Method Resolution Order (MRO) to resolve this ambiguity, which is determined by the C3 linearization algorithm.

Method Resolution Order (MRO)

The MRO is the order in which Python looks for a method in a hierarchy of classes. You can view the MRO of a class using the __mro__ attribute or the mro() method.

Example:

print(D.__mro__)
# Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

print(D.mro())
# Output: [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

The MRO ensures a consistent and predictable order of method resolution, preventing the ambiguities that can arise with multiple inheritance.

Advantages and Disadvantages of Multiple Inheritance

Advantages:

  • Code Reuse: Allows for sharing functionality between multiple classes.

  • Flexibility: Enables more complex relationships between classes, supporting richer and more modular designs.

Disadvantages:

  • Complexity: Can lead to complex and difficult-to-understand class hierarchies.

  • Ambiguity: May introduce ambiguity in method resolution, especially in the case of the Diamond Problem.

  • Maintenance: More difficult to maintain and debug compared to single inheritance.

Multiple inheritance can be a powerful tool in Python, but it should be used carefully to avoid complexity and ambiguity in your code. Understanding the principles of MRO and the potential pitfalls of multiple inheritance can help you leverage its benefits while mitigating its challenges.

Conclusion

Now I bet you are well equipped to be a better Python Developer😂.

Keep coding, keep learning, and enjoy the endless possibilities that Python has to offer!

That's all folks. Leave a like and some lovely critics in the comments😁.

Signing off!!!👋