OOP part2#

3. Polymorphism:#

Polymorphism refers to the ability of different objects to respond to the same message or method call in different ways. In other words, polymorphism allows objects of different classes to be treated as objects of a common superclass, enabling code to be written in a generic way that can operate on objects of different types. There are two main types of polymorphism:

  1. Compile-time Polymorphism (Static Binding): Also known as method overloading and operator overloading, compile-time polymorphism occurs when different implementations of a method or operator are provided for different parameter types or numbers.

Example of method overloading:

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

    def add(self, a, b, c):
        return a + b + c

math = MathOperations()
# print(math.add(2, 3))       # Output: Error - Second add method is overwritten
print(math.add(2, 3, 4))    # Output: 9
9
  1. Run-time Polymorphism (Dynamic Binding): Also known as method overriding, run-time polymorphism occurs when a subclass provides a specific implementation of a method that is already defined in its superclass.

Example of method overriding:

# Method Overriding
class Phone:
    def __init__(self, price, brand, camera):
        print ("Inside phone constructor")
        self.__price = price
        self.brand = brand
        self.camera = camera

    def buy(self):
        print ("Buying a phone")

class SmartPhone(Phone):
    def buy(self):
        print ("Buying a smartphone")

s=SmartPhone(20000, "Apple", 13)

s.buy()
Inside phone constructor
Buying a smartphone

Operator Overloading:#

Operator overloading refers to the ability to redefine the behavior of operators such as +, -, *, /, etc., for user-defined classes. By overloading operators, objects of user-defined classes can support arithmetic, comparison, and other operations just like built-in types.

Example of operator overloading:

'hello' + 'world'
'helloworld'
4 + 5
9
[1,2,3] + [4,5]
[1, 2, 3, 4, 5]
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __str__(self):
        return f"({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(4, 5)
print(v1 + v2)  # Output: (6, 8)
(6, 8)

In this example, the __add__() method is overloaded to define the addition operation for objects of the Vector class. When v1 + v2 is executed, Python calls the __add__() method of the v1 object and passes v2 as the other parameter.

Polymorphism and operator overloading enhance code readability, maintainability, and expressiveness by allowing for the creation of more intuitive and concise code. They are powerful features of object-oriented programming that enable developers to write more flexible and reusable code.

Magic Functions/Dunder Functions#

Magic functions, also known as dunder (double underscore) functions or special methods, are predefined methods in Python classes that have double underscores (__) at the beginning and end of their names. These methods allow classes to define behavior that will be invoked in response to certain language-specific operations, such as arithmetic operations, comparison operations, object creation, and more.

Magic functions are often used to customize the behavior of objects and enable them to interact with operators, functions, and built-in language constructs in a way that’s intuitive and consistent with Python’s syntax and semantics.

Dynamic Polymorphism#

Dynamic polymorphism, also known as runtime polymorphism or late binding, is a feature in object-oriented programming that allows a subclass to provide a specific implementation of a method that is already defined in its superclass. This allows for the same method name to behave differently based on the type of object calling it.

Dynamic polymorphism enables a more flexible and extensible design, as it allows code to be written in a way that can operate on objects of different types without the need for explicit type checking.

Here’s an example to illustrate dynamic polymorphism in Python:

class Animal:
    def sound(self):
        print("Animal makes a sound")

class Dog(Animal):
    def sound(self):
        print("Dog barks")

class Cat(Animal):
    def sound(self):
        print("Cat meows")

# Example of dynamic polymorphism
def make_sound(animal):
    animal.sound()

# Create instances of different classes
animal1 = Animal()
animal2 = Dog()
animal3 = Cat()

# Call make_sound function with objects of different classes
make_sound(animal1)  # Output: Animal makes a sound
make_sound(animal2)  # Output: Dog barks
make_sound(animal3)  # Output: Cat meows
Animal makes a sound
Dog barks
Cat meows

In this example:

  • We have a base class Animal with a method sound().

  • We have two subclasses Dog and Cat that override the sound() method with their own implementations.

  • The make_sound() function takes an Animal object as a parameter and calls its sound() method.

  • When make_sound() is called with objects of different subclasses, the appropriate implementation of the sound() method is invoked based on the actual type of the object at runtime. This is dynamic polymorphism in action.

Dynamic polymorphism allows for more flexible and modular code, as it allows different objects to respond to the same method call in different ways based on their specific implementations. This promotes code reuse, simplifies design, and enhances maintainability.

4. Abstract Method and Class:#

In object-oriented programming, an abstract method is a method declared in a class definition, but it doesn’t provide an implementation in the class itself. Instead, the implementation is expected to be provided by subclasses. Similarly, an abstract class is a class that cannot be instantiated directly and may contain one or more abstract methods. Abstract classes are designed to serve as blueprints for other classes to inherit from and provide concrete implementations for abstract methods.

In Python, abstract classes can be defined using the abc module, which stands for Abstract Base Classes.

Example of an abstract class with an abstract method in Python:

from abc import ABC, abstractmethod

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

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

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

rectangle = Rectangle(5, 3)
print(rectangle.area())  # Output: 15
15

In this example:

  • Shape is an abstract class that defines an abstract method area().

  • Rectangle is a subclass of Shape that provides a concrete implementation for the area() method.

  • We can instantiate objects of the Rectangle class and call the area() method, but we cannot instantiate objects of the Shape class directly because it’s an abstract class.

Empty Class:#

An empty class is a class that doesn’t contain any attributes or methods. It serves as a placeholder or a base for future development. Empty classes are sometimes used as markers or placeholders in code, and they can be extended later to add functionality.

Example of an empty class in Python:

class Placeholder:
    pass

This class doesn’t contain any attributes or methods. It can be instantiated, but it doesn’t have any behavior associated with it unless additional attributes or methods are added.

Data Class:#

A data class is a class that primarily holds data and doesn’t contain any behavior (i.e., methods). Data classes are used to create simple data structures to store and manipulate data. In Python, the dataclasses module provides a decorator and functions for automatically adding special methods such as __init__(), __repr__(), __eq__(), and __hash__() to a class based on its attributes.

Example of a data class in Python:

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p1 = Point(3, 5)
print(p1)  # Output: Point(x=3, y=5)
Point(x=3, y=5)

In this example, the @dataclass decorator automatically generates an __init__(), __repr__(), __eq__(), and __hash__() methods based on the attributes x and y. This simplifies the creation of simple data-holding classes in Python.

Keyword Arguments#

Keyword arguments are a feature in Python that allows you to pass arguments to a function using their corresponding parameter names. When calling a function, you can specify the values for parameters by explicitly mentioning the parameter names along with their values. This provides greater flexibility and clarity in function calls, especially when dealing with functions that have many parameters or default parameter values.

Here’s how keyword arguments work in Python:

def greet(name, message):
    print(f"Hello, {name}! {message}")

# Using positional arguments
greet("Ali", "How are you?")  # Output: Hello, Ali! How are you?

# Using keyword arguments
greet(message="How are you?", name="Bob")  # Output: Hello, Bob! How are you?
Hello, Ali! How are you?
Hello, Bob! How are you?

In this example:

  • The greet() function accepts two parameters: name and message.

  • When calling the function with positional arguments, the arguments are matched based on their order.

  • When calling the function with keyword arguments, the arguments are matched based on their names, regardless of their order.

  • Keyword arguments allow you to specify arguments in any order and skip optional arguments by providing only the values you want to change.