Object-Oriented Programming (OOP): A Fundamental Concept

Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around data, or objects, rather than functions and logic. Python is an object-oriented language that supports OOP concepts like classes, objects, inheritance, encapsulation, and polymorphism. At its core, OOP revolves around the concept of objects, which are self-contained units consisting of both data (attributes) and behavior (methods).

Understanding Classes and Objects

What is a Class?

A class is a blueprint for creating objects (a particular data structure). Classes encapsulate data for the object and methods to manipulate that data.

What is an Object?

An object is an instance of a class. When a class is defined, no memory is allocated until an object of that class is created.

Basic Syntax of a Class:


                    class ClassName:
                        # Class attributes (optional)
                        attribute1 = value1
                        attribute2 = value2
                    
                        # Constructor method (optional)
                        def __init__(self, parameter1, parameter2):
                            self.parameter1 = parameter1
                            self.parameter2 = parameter2
                        
                        # Other methods (optional)
                        def method1(self):
                            # Method body
                            pass
                        
                        def method2(self, parameter):
                            # Method body
                            pass
                    

Here class CLasssName decalres the start of the class definition (where ClassName represents the name of the class). The 'Class Attribute' are the variables defined with the class scope. They are shared among all instances of the class. The __init__ special method, also known as a Constructor, is used to initialize the class with its attributes. Here self parameter represents the instance of the class, allowing us to access its attributes and methods.

Example: Creating a Class and Object


                    class Dog:
                        def __init__(self, name, breed):
                            self.name = name
                            self.breed = breed
                    
                        def bark(self):
                            return f"{self.name} says Woof!"
                    
and therefore,

                        # Creating an object of the Dog class
                        my_dog = Dog("Buddy", "Golden Retriever")
                        
                        # Accessing object properties and methods
                        print(my_dog.name)    # Output: Buddy
                        print(my_dog.breed)   # Output: Golden Retriever
                        print(my_dog.bark())  # Output: Buddy says Woof!
                    

Inheritance in Python

Inheritance is a key feature of OOP that allows a new class to inherit the properties and methods of an existing class. The new class is called the child class (or subclass), and the existing class is the parent class (or superclass).

Basic Syntax of Inheritance:


                    class ParentClass:
                        def __init__(self, param):
                            self.param = param
                    
                        def parent_method(self):
                            return f"Parent method called with {self.param}"
                    
                    class ChildClass(ParentClass):
                        def __init__(self, param, child_param):
                            super().__init__(param)  # Call the constructor of the parent class
                            self.child_param = child_param
                    
                        def child_method(self):
                            return f"Child method called with {self.child_param}"
                    
where:
  • super(): Used to call a method from the parent class.
  • Method Overriding: Child classes can override methods from the parent class by defining methods with the same name.
  • Multiple Inheritance: Python supports multiple inheritance, where a class can inherit from multiple parent classes.
This concept is very useful for structuring code in a more modular and maintainable way.

Example of single inheritance:


                    class Animal:
                        def __init__(self, species):
                            self.species = species
                    
                        def make_sound(self):
                            return "Some generic sound"
                    
                    class Dog(Animal):
                        def __init__(self, species, name):
                            super().__init__(species)  # Inherit species from Animal
                            self.name = name
                    
                        def bark(self):
                            return f"{self.name} says Woof!"
                    
                    # Creating an object of the Dog class
                    my_dog = Dog("Canine", "Buddy")
                    
                    # Accessing inherited and new methods
                    print(my_dog.species)      # Output: Canine
                    print(my_dog.make_sound()) # Output: Some generic sound
                    print(my_dog.bark())       # Output: Buddy says Woof!
                    
Method Overriding
Method overriding allows a child class to provide a specific implementation of a method that is already defined in its parent class.

                    class Animal:
                        def make_sound(self):
                            return "Some generic sound"
                    
                    class Dog(Animal):
                        def make_sound(self):
                            return "Woof Woof!"
                    
                    # Creating objects
                    generic_animal = Animal()
                    dog = Dog()
                    
                    print(generic_animal.make_sound())  # Output: Some generic sound
                    print(dog.make_sound())             # Output: Woof Woof!
                    
Multiple Inheritance
Python supports multiple inheritance, where a class can inherit from more than one parent class.

                    class Bird:
                        def fly(self):
                            return "Flies in the sky"
                    
                    class Fish:
                        def swim(self):
                            return "Swims in the water"
                    
                    class FlyingFish(Bird, Fish):
                        pass
                    
                    # Creating an object of FlyingFish
                    flying_fish = FlyingFish()
                    
                    # Accessing methods from both parent classes
                    print(flying_fish.fly())  # Output: Flies in the sky
                    print(flying_fish.swim()) # Output: Swims in the water
                    

Encapsulation and Access Modifiers

Encapsulation is the mechanism of restricting access to some of the object's components. Python uses underscores to indicate the intended level of access.
Public, Protected, and Private Members:
  • Public: Accessible from anywhere.
  • Protected: Indicated by a single underscore '_''. Meant for internal use within the class and subclasses but not restricted.
  • Private: Indicated by a double underscore '__'. Intended to be inaccessible from outside the class.

Example: Encapsulation


                    class MyClass:
                        def __init__(self):
                            self.public_var = "I am public"
                            self._protected_var = "I am protected"
                            self.__private_var = "I am private"
                    
                        def get_private_var(self):
                            return self.__private_var
                    
                    # Creating an object of MyClass
                    obj = MyClass()
                    
                    # Accessing public variable
                    print(obj.public_var)  # Output: I am public
                    
                    # Accessing protected variable (possible but not recommended)
                    print(obj._protected_var)  # Output: I am protected
                    
                    # Accessing private variable (will raise an AttributeError)
                    # print(obj.__private_var)  # Uncommenting this will cause an error
                    
                    # Accessing private variable through a method
                    print(obj.get_private_var())  # Output: I am private
                    
Polymorphism in Python
Polymorphism allows objects of different classes to be treated as objects of a common superclass. It typically refers to the ability to call the same method on different objects and have each of them respond in a way appropriate to their class.

Example: Polymorphism with Methods:


                        class Cat:
                            def make_sound(self):
                                return "Meow"
                        
                        class Dog:
                            def make_sound(self):
                                return "Woof"
                        
                        def animal_sound(animal):
                            print(animal.make_sound())
                        
                        # Creating objects
                        cat = Cat()
                        dog = Dog()
                        
                        # Polymorphic behavior
                        animal_sound(cat)  # Output: Meow
                        animal_sound(dog)  # Output: Woof
                    

Classes vs Instances

Classes allow you to create user-defined data structures. Classes define functions called methods, which identify the behaviors and actions that an object created from the class can perform with its data.

While the class is the blueprint, an instance is an object that’s built from a class and contains real data. An instance of the Dog class is not a blueprint anymore. It’s an actual dog with a name, like Miles, who’s four years old.

Example: 1

Here's a simple example of a class definition in Python:


                            class Car:
                                # Class attribute
                                num_wheels = 4
                            
                                # Constructor method
                                def __init__(self, color, model):
                                    self.color = color
                                    self.model = model
                                
                                # Method to display car details
                                def display_details(self):
                                    print(f"Color: {self.color}, Model: {self.model}, Wheels: {Car.num_wheels}")                    
                        

Here:

  • class attributes: are attribures that have the same value for all class instances. We can define a class attribute by assigning a value to a variable name outside of .__init__().
  • instance attributes: Attributes created in .__init__() are called 'instance attributes'. An instance attribute’s value is specific to a particular instance of the class.
    • self.color=color: creates an attribute called color and assigns the value of the color parameter to it.
    • self.model: creates an attribute called model and assigns the value of the model parameter to it.

So creating an instane of the Car class:
> my_car = Car("Red", "Toyota")
So Accesing the attrinutes and calling methods of the object:
> print(my_car.color)
Output: Red
> my_car.display_details()
Output: Color: Red, Model: Toyota, Wheels: 4
We define the properties that all 'Car' objects must have in a method called .__init__(). Every time you create a new Car object, .__init__() sets the initial state of the object by assigning the values of the object’s properties. That is, .__init__() initializes each new instance of the class. We can give .__init__() any number of parameters, but the first parameter will always be a variable called self. When we create a new class instance, then Python automatically passes the instance to the self parameter in .__init__() so that Python can define the new attributes on the object.

Example: 2

  • (a). Define a new Complex class with 2 attributes: part_re which contains the real part of a complex number, and part_im which contains the imaginary part of a complex number.
  • (b). Define in the Complex class a display method which prints a Complex in its algebraic form a ± bi. This method should adapt to the sign of the imaginary part (The method should be able to display 4 - 2i,6 + 2i, 5, ...).
  • (c). Instantiate two Complex objects corresponding to the complex numbers 4+5𝑖, and 3−2𝑖, then print them on the console.

                        class complex:
                            def __init__(self, part_re, part_im):
                                self.part_re = part_re
                                self.part_im = part_im
                            # (b) display method
                            def display(self):
                                if self.part_im >= 0:
                                    print(f"{self.part_re} + {self.part_im}i")
                                else:
                                    print(f"{self.part_re} - {abs(self.part_im)}i")
                    

                        # (c) printing 4+5i, 3-2i
                            
                        c1 = complex(4, 5)
                        c1.display() # display the c1
                        
                        c2 = complex(3, -2)
                        c2.display() # display the c2
                    
Output:
                        4 + 5i
                        3 - 2i
                    

Example: 3 The flexibility of classes in object-oriented programming allows the developer to broaden a class by adding new attributes and methods to it. All instances of this class will then be able to call these methods.

For example, we can define in the Vehicle class a new add method which will add an individual to the passenger list:


                        class Vehicle:
                            def __init__(self, a, b=[]):
                                self.seats = a
                                self.passengers = b
                            
                            def print_passengers(self):
                                for i in range(len(self.passengers)):
                                    print(self.passengers[i])
                            
                            def add(self,name): #New methd
                                self.passengers.append(name)
                            
                        car1 = Vehicle(4, ['Charles', 'Paul'])  # Instantiation  of car1
                        car1.add('Raphaël')                     # 'Raphaël' is added to the list of passengers
                        
                        car1.print_passengers()             # Display of the list of passengers
                    
Output:
                        Charles
                        Paul
                        Raphaël
                    

Example: 1 Let's consider the Vehicle class again:


                        class Vehicle:
                            def __init__(self, a, b = []):
                                self.seats = a
                                self.passengers = b
                            def print_passengers(self):
                                for i in range (len (self.passengers)):
                                    print (self.passengers [i])
                            def add(self, name):
                                    self.passengers.append (name)
                    

We can define a Motorcycle class which inherits from theVehicle class as follows:


                        class Motorcycle(Vehicle):
                            def__init__(self, b, c):
                                self.seats = 2
                                self.passengers = b
                                self.brand = c
                        Motorcycle = Motorcycle(['Pierre', 'Dimitri'], 'Yamaha')
                    

By rewriting the __init__ method, any Motorcycle object will automatically have 2 seats and a new brand attribute. So following code gives the list of passengers:


                        moto1 = Motorcycle(['Pierre','Dimitri'], 'Yamaha')
                        moto1.add('Yohann')
                        moto1.print_passengers()
                    
Output:
                        Pierre
                        Dimitri
                        Yohann
                    

We see that even with the number of seats 2, we can add more number of passngers to the moto class. We need to modify the Motorcycle class.


                        class Motorcycle(Vehicle):
                            def__init__(self, b, c):
                                self.seats = 2
                                self.passengers = b
                                self.brand = c

                            def add(self, name):
                                if len(self.passengers) < self.seats:
                                    super().add(name)
                                    remaining_seats = self.seats - len(self.passengers)
                                    print(f"{name} added to the list. {remaining_seats} seat(s) remaining.")
                                else:
                                    print("The vehicle is full.")
                    

                        # Test the Motorcycle class
                        moto1 = Motorcycle(['Pierre', 'Dimitri'], 'Yamaha')
                        moto1.add('Yohann')
                        moto1.print_passengers()
                        moto1.add('Sophie')
                    
Output:
                        The vehicle is full.
                        Pierre
                        Dimitri
                        The vehicle is full.
                    

Predefined classes

In Python, many predefined classes such as the list,tuple or str classes are regularly used to facilitate the developer's tasks. Like all other classes, they have their own attributes and methods that are available to the user. One of the great interests of object oriented programming is to be able to create classes and share them with other developers. This is done through packages such as numpy, pandas or scikit-learn. All of these packages are actually classes created by other developers in the Python community to give us tools that will make easier to develop our own algorithms. To know all the available attributes in a given class, we just need dir(className). For example, dir(list) will give all available attributes in the list class.

Built-in methods

All classes defined in Python have methods whose name is already defined. The first example of such a method we have seen is the __init__ method which allows us to initialize an object, but it is not the only one. Built-in methods give the class the ability to interact with predefined Python functions such as print, len, help and basic operators. These methods usually have the affixes __ at the beginning and end of their names, which allows us to easily identify them. The dir(object) lists all available predefined methods common to all python objects:
Attribute/Method Description Example
__class__ Returns the class of the object df.__class__
__delattr__ Deletes an attribute del df.attribute_name
__dir__ Returns the list of attributes and methods of an object dir(df)
__doc__ Returns the docstring (documentation) of the object df.__doc__
__eq__ Compares two objects for equality df1 == df2
__format__ Returns a formatted string representation of the object formatted_str = df.__format__("csv")
__ge__ Compares greater than or equal to df1 >= df2
__getattribute__ Returns the value of an attribute of an object value = df.__getattribute__("column_name")
__gt__ Compares greater than df1 > df2
__hash__ Returns a hash value for the object hash_value = hash(df)
__init__ Initializes an object df = pd.DataFrame(data)
__init_subclass__ Called when a subclass is initialized class CustomDataFrame(pd.DataFrame):
def __init_subclass__(cls):
print("CustomDataFrame subclass created")
__le__ Compares less than or equal to df1 <= df2
__lt__ Compares less than df1 < df2
__ne__ Compares not equal to df1 != df2
__new__ Creates a new instance of a class df = pd.DataFrame()
__reduce__ Used for pickling reduced_obj = df.__reduce__()
__reduce_ex__ Used for pickling reduced_obj = df.__reduce_ex__(protocol)
__repr__ Returns a string representation of the object repr_str = df.__repr__()
__setattr__ Sets the value of an attribute of an object df.__setattr__("attribute_name", value)
__sizeof__ Returns the size of the object in memory size = df.__sizeof__()
__str__ Returns a string representation of the object str_rep = df.__str__()
__subclasshook__ Used to customize subclass checks is_subclass = DataFrame.__subclasshook__(subclass)

This table provides a brief description of each attribute and method associated with the DataFrame class in pandas, along with examples demonstrating their usage.

Example: Instantiate a Complex object corresponding to the number 6−3𝑖 then display it on the console using the print function.


                        class Complex:
                            def __init__(self, a = 0, b = 0):
                                self.part_re = a
                                self.part_im = b
                            
                            def __str__(self):
                                if(self.part_im < 0):
                                    return self.part_re.__str__() + self.part_im.__str__() + 'i'  # returns 'a' '-b' 'i'
                                
                                if(self.part_im == 0):
                                    return self.part_re.__str__()    # returns 'a'
                                
                                if(self.part_im > 0):
                                    return self.part_re.__str__() + '+' + self.part_im.__str__() + 'i' # returns 'a' '+' 'b' + 'i'
                        
                        complex_number = Complex(6, -3)
                        print(complex_number)  
                    
It preints:
6-3 i

Example:

  • (a) Define in the Complex class a mod method which returns the modulus of the Complex calling the method. You can use the sqrt function of the numpy package to calculate a square root.
  • (b) Define in the Complex class the methods __lt__ and __gt__ (strictly lower and strictly higher). These methods must return a boolean.
  • (c) Perform the two comparisons defined above on the complex numbers 3+4𝑖 and 2−5𝑖 .

                        import numpy as np
                        class Complex:
                            def __init__(self, a = 0, b = 0):
                                self.part_re = a
                                self.part_im = b
                            
                            def __str__(self):
                                if(self.part_im < 0):
                                    return self.part_re.__str__() + self.part_im.__str__() + 'i'  # returns 'a' '-b' 'i'
                                
                                if(self.part_im == 0):
                                    return self.part_re.__str__()    # returns 'a'
                                
                                if(self.part_im > 0):
                                    return self.part_re.__str__() + '+' + self.part_im.__str__() + 'i' # returns 'a' '+' 'b' + 'i'
                                
                            def mod(self):
                                return np.sqrt( self.part_re ** 2 + self.part_im ** 2)  # returns (sqrt(a² + b²))
                            
                            def __lt__(self, other):    
                                if(self.mod() < other.mod()):   # returns True if |self| < |other|
                                    return True
                                else:
                                    return False
                                
                            def __gt__(self, other):
                                if(self.mod() > other.mod()):   # returns True if |self| > |other|
                                    return True
                                else:
                                    return False
                                
                                
                        z1 = Complex(3, 4)
                        z2 = Complex(2, 5)
                        print(z1 > z2)
                        print(z1 < z2)
                    
                        False
                        True
                    

Some other interesting things to know: