Object Oriented Programming
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!"
                    
                        # 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}"
                    - 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.
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- colorand assigns the value of the- colorparameter to it.
- self.model: creates an attribute called- modeland assigns the value of the- modelparameter to it.
> 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
.__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_rewhich contains the real part of a complex number, andpart_imwhich contains the imaginary part of a complex number.
- (b). Define in the Complexclass adisplaymethod 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 needdir(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)  
                    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:
- Visit my website on For Data, Big Data, Data-modeling, Datawarehouse, SQL, cloud-compute.
- Visit my website on Data engineering
 
            