OOP part1#

Intro to OOP(Object Oriented Programming)#

Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around objects and data rather than actions and logic. It enables the creation of modular and reusable code by representing real-world entities as objects with attributes (data) and methods (functions) that operate on the data.

In object-oriented programming (OOP), classes and objects are fundamental concepts.

Classes:#

A class is a blueprint or template for creating objects. It defines the attributes (data members) and methods (functions) that objects of the class will have. Classes provide a way to organize and structure code by encapsulating related data and behavior.

Objects:#

An object is an instance of a class. It represents a specific instantiation of the class, with its own unique data values for the attributes defined in the class. Objects can also invoke the methods defined in their class to perform specific actions.

L = [1,2,3]

L.upper()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[1], line 3
      1 L = [1,2,3]
----> 3 L.upper()

AttributeError: 'list' object has no attribute 'upper'
s = 'hello'
s.append('x')
L = [1,2,3]
print(type(L))
s = [1,2,3]
# syntax to create an object
#objectname = classname()
# object literal
L = [1,2,3]
L = list()
L
s = str()
s
class Person:
    name = 'fahad'
    occupation = 'Data Scientist'
    age = 20
    def info(self):
        print(f"{self.name} is a {self.occupation} and {self.age} years old")

a = Person()
a.info()
fahad is a Data Scientist and 20 years old
class Person:
    def __init__(self, name, occupation, age):
        self.name = name
        self.occupation = occupation
        self.age = age
    def info(self):    
        print(f"{self.name} is a {self.occupation} and {self.age} years old")

a = Person("Fahad", "Data Scientist", 20)
b = Person("Ali", "Software Developer", 30)
a.info()
b.info()
Fahad is a Data Scientist and 20 years old
Ali is a Software Developer and 30 years old

Instance Variables: (Jis ki value alag alag objects ke liye alag alag hogi)#

Instance variables, also known as member variables or attributes, are properties that belong to individual objects. Each object has its own set of instance variables, which can have different values. These variables define the state of the object and are accessed using the dot notation (object.variable).

class Car:
    def __init__(self, brand, model):
        self.brand = brand  # Instance variable
        self.model = model  # Instance variable

In this example, brand and model are instance variables specific to each Car object.

Instance Methods:#

Instance methods, also known as member methods or instance functions, are functions that operate on the instance variables of an object. They are defined within a class and are invoked on individual objects using the dot notation (object.method()).

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

    def drive(self):  # Instance method
        print(f"{self.brand} {self.model} is driving.")

Here, drive() is an instance method that operates on the brand and model instance variables of a Car object.

When creating objects of a class, each object gets its own copy of instance variables, and they can be accessed and modified independently. Similarly, each object can call its instance methods, which can access and manipulate its instance variables.

Class Variables:#

Class variables, also known as static variables, are variables that are shared among all instances (objects) of a class. They are defined at the class level and are accessed using the class name rather than through an instance of the class. Class variables are typically used to store data that is common to all instances of the class.

For example, in a Car class:

class Car:
    car_count = 0  # Class variable

    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
        Car.car_count += 1  # Accessing and modifying class variable

In this example, car_count is a class variable that keeps track of the total number of cars created.

Class Functions:#

Class functions, also known as static methods, are methods that are associated with the class rather than with individual instances. They are defined using the @staticmethod decorator in Python and do not have access to instance variables or instance methods. Class functions are often used for utility functions or operations that do not require access to instance-specific data.

class Car:
    car_count = 0  # Class variable

    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
        Car.car_count += 1  # Accessing and modifying class variable

    @staticmethod
    def display_total_cars():  # Class function
        print(f"Total cars: {Car.car_count}")

Here, display_total_cars() is a class function that displays the total number of cars created.

Class variables and functions provide a way to define data and behavior that is shared among all instances of a class. They are useful for maintaining state or performing operations that are independent of any particular instance. However, it’s important to use them judiciously and understand their implications for the design and behavior of the class.

Constructors:#

A constructor is a special method that is automatically called when an object is created. It is used to initialize the object’s state, allocate resources, and perform any necessary setup. In many programming languages such as Python, constructors are typically named init().

For example, in a Car class:

class Car:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
        print(f"A {self.brand} {self.model} has been created.")

In this example, init() is the constructor method. When a Car object is created, this method is automatically called to initialize the brand and model attributes.

Destructors:#

A destructor is a special method that is called when an object is destroyed or goes out of scope. It is used to perform cleanup tasks, release resources, and deallocate memory associated with the object. In some programming languages, such as C++, destructors are explicitly defined, while in others, such as Python, destructors are less commonly used due to automatic memory management (garbage collection). In Python, the destructor method is named del().

For example, in the same Car class:

class Car:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
        print(f"A {self.brand} {self.model} has been created.")

    def __del__(self):
        print(f"The {self.brand} {self.model} has been destroyed.")

In this example, del() is the destructor method. When a Car object is destroyed, this method is automatically called to perform any necessary cleanup tasks.

Constructors and destructors are important for managing the lifecycle of objects, ensuring that resources are properly initialized and released. However, it’s important to use them judiciously and be aware of potential side effects, especially in languages with manual memory management. In Python, due to automatic memory management, destructors are not as commonly used, and cleanup tasks are often handled implicitly by the garbage collector.

Inheritance#

Inheritance is a key concept in object-oriented programming that allows a class to inherit attributes and methods from another class. This promotes code reusability and facilitates the creation of hierarchical relationships between classes.

Inheritance: Inheritance enables a new class (subclass or derived class) to inherit properties and behaviors (attributes and methods) from an existing class (superclass or base class). The subclass can then extend or override the inherited functionalities, and it can also introduce its own unique properties and methods.

# constructor example

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):
    pass

s=SmartPhone(20000, "Apple", 13)
s.buy()
Inside phone constructor
Buying a phone
# constructor example 2

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

class SmartPhone(Phone):
    def __init__(self, os, ram):
        self.os = os
        self.ram = ram
        print ("Inside SmartPhone constructor")

s=SmartPhone("Android", 2)
s.brand
Inside SmartPhone constructor
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[11], line 17
     14         print ("Inside SmartPhone constructor")
     16 s=SmartPhone("Android", 2)
---> 17 s.brand

AttributeError: 'SmartPhone' object has no attribute 'brand'
# child can't access private members of the class

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

    #getter
    def show(self):
        print (self.__price)

class SmartPhone(Phone):
    def check(self):
        print(self.__price)

s=SmartPhone(20000, "Apple", 13)
print(s.brand)
# s.__price()
# s.check()
# s.show()
Inside phone constructor
Apple
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")
        # syntax to call parent ka buy method
        super().buy()

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

s.buy()
Inside phone constructor
Buying a smartphone
Buying a phone
class Animal:
    def __init__(self, species):
        self.species = species

    def make_sound(self):
        pass  # Placeholder method

class Dog(Animal):
    def __init__(self, breed):
        super().__init__("Dog")
        self.breed = breed

    def make_sound(self):
        return "Woof!"

class Cat(Animal):
    def __init__(self, breed):
        super().__init__("Cat")
        self.breed = breed

    def make_sound(self):
        return "Meow!"

# Usage
dog = Dog("Golden Retriever")
print(dog.make_sound())  # Output: Woof!

cat = Cat("Siamese")
print(cat.make_sound())  # Output: Meow!
Woof!
Meow!

In this example, when an object animal of the Animal class is created, the __init__() method is automatically called with the value "Dog" passed as the species parameter. Inside the method, the species parameter is assigned to the species attribute of the object using the self.species syntax, initializing the state of the object.

Multilevel Inheritance:#

Multilevel inheritance refers to a scenario where a subclass inherits from another subclass, creating a chain of inheritance. This means that a class can inherit properties and methods not only from its immediate superclass but also from its superclass’s superclass, and so on. For example, Class A may be the superclass of Class B, and Class B may be the superclass of Class C.

# multilevel
class Product:
    def review(self):
        print ("Product customer review")

class Phone(Product):
    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):
    pass

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

s.buy()
s.review()
Inside phone constructor
Buying a phone
Product customer review
class Animal:
    def __init__(self, species):
        self.species = species

    def sound(self):
        return "Animal sound"

class Dog(Animal):
    def __init__(self, breed):
        super().__init__("Dog")
        self.breed = breed

    def sound(self):
        return "Woof!"

class GoldenRetriever(Dog):
    def __init__(self, name):
        super().__init__("Golden Retriever")
        self.name = name

    def sound(self):
        return super().sound()  # Calling superclass method

# Create an instance of GoldenRetriever
dog = GoldenRetriever("Buddy")
print(dog.species)  # Output: Dog
print(dog.breed)    # Output: Golden Retriever
print(dog.name)     # Output: Buddy
print(dog.sound())  # Output: Woof!
Dog
Golden Retriever
Buddy
Woof!

In this example:

  • We have three classes: Animal, Dog, and GoldenRetriever.

  • GoldenRetriever is a subclass of Dog, and Dog is a subclass of Animal. This establishes a chain of inheritance, forming a multilevel hierarchy.

  • Each class has its own constructor (__init__ method) and additional methods.

  • The GoldenRetriever class inherits from Dog, which in turn inherits from Animal. Thus, GoldenRetriever inherits properties and methods from both Dog and Animal.

  • We create an instance of GoldenRetriever named “Buddy” and demonstrate accessing its attributes and invoking its methods.

Multilevel inheritance allows for deeper specialization and customization of classes while promoting code reuse. However, it’s important to use it judiciously to avoid creating overly complex class hierarchies.

Hierarchical Inheritance:#

Hierarchical inheritance occurs when multiple classes inherit from the same superclass. This creates a hierarchical structure where multiple subclasses share a common base class. Each subclass inherits the properties and methods of the superclass but can also have its own additional features. For example, Class A is the superclass, and both Class B and Class C inherit from Class A.

# Hierarchical
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):
    pass

class FeaturePhone(Phone):
    pass

SmartPhone(1000,"Apple","13px").buy()
FeaturePhone(10,"Lava","1px").buy()
Inside phone constructor
Buying a phone
Inside phone constructor
Buying a phone
class Animal:
    def __init__(self, species):
        self.species = species

    def sound(self):
        return "Animal sound"

class Dog(Animal):
    def __init__(self, breed):
        super().__init__("Dog")
        self.breed = breed

    def sound(self):
        return "Woof!"

class Cat(Animal):
    def __init__(self, breed):
        super().__init__("Cat")
        self.breed = breed

    def sound(self):
        return "Meow!"

# Create instances of Dog and Cat
dog = Dog("Golden Retriever")
cat = Cat("Siamese")

print(dog.species)  # Output: Dog
print(cat.species)  # Output: Cat

print(dog.breed)    # Output: Golden Retriever
print(cat.breed)    # Output: Siamese

print(dog.sound())  # Output: Woof!
print(cat.sound())  # Output: Meow!
Dog
Cat
Golden Retriever
Siamese
Woof!
Meow!

In this example:

  • We have three classes: Animal, Dog, and Cat.

  • Both Dog and Cat classes are subclasses of the Animal class, establishing hierarchical inheritance.

  • Each subclass (Dog and Cat) inherits properties and methods from the common superclass (Animal), such as the species attribute and the sound() method.

  • Each subclass (Dog and Cat) also defines its own unique attributes and methods (breed attribute and custom sound() method).

  • We create instances of Dog and Cat and demonstrate accessing their attributes and invoking their methods.

Hierarchical inheritance allows for multiple specialized subclasses to inherit from a common superclass, promoting code reuse and organization. It facilitates the modeling of real-world relationships where different entities share common characteristics but also have their own unique features.

Multiple Inheritance:#

Multiple inheritance is a feature in object-oriented programming where a subclass can inherit from more than one superclass. This allows the subclass to inherit attributes and methods from multiple parent classes, enabling the combination of features from different sources.

Here’s an example to illustrate multiple inheritance:

# Multiple
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 Product:
    def review(self):
        print ("Customer review")

class SmartPhone(Phone, Product):
    pass

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

s.buy()
s.review()
Inside phone constructor
Buying a phone
Customer review
class A:
    def method_a(self):
        print("Method A from class A")

class B:
    def method_b(self):
        print("Method B from class B")

class C(A, B):
    def method_c(self):
        print("Method C from class C")

# Create an instance of class C
obj_c = C()

# Call methods from class A
obj_c.method_a()  # Output: Method A from class A

# Call methods from class B
obj_c.method_b()  # Output: Method B from class B

# Call methods from class C
obj_c.method_c()  # Output: Method C from class C
Method A from class A
Method B from class B
Method C from class C

In this example:

  • Class C inherits from both classes A and B, allowing instances of class C to access methods from both parent classes.

  • The instance obj_c of class C can call methods method_a() from class A, method_b() from class B, and method_c() from class C.

Method Resolution Order#

Method Resolution Order (MRO) determines the order in which base classes are searched when resolving methods or attributes of a class during inheritance. It ensures that the correct method or attribute is found and used, especially in cases of multiple inheritance where there may be method or attribute conflicts between parent classes.

Python uses the C3 linearization algorithm to compute the Method Resolution Order. The method resolution order can be accessed using the __mro__ attribute or the mro() method of a class.

Here’s an example demonstrating the method resolution order:

# the diamond problem
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 Product:
    def buy(self):
        print ("Product buy method")

# Method resolution order
class SmartPhone(Phone,Product):
    pass

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

s.buy()
Inside phone constructor
Buying a phone
class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

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

In this example, the method resolution order for class D is (D, B, C, A, object), meaning that methods and attributes will be searched for first in class D, then in class B, then in class C, then in class A, and finally in the base class object.

Access Specifiers:#

Access specifiers are keywords or conventions used in object-oriented programming languages to control the visibility and accessibility of class members (attributes and methods) from outside the class. They determine whether a class member can be accessed or modified by code outside the class.

The three common access specifiers are:

  1. Public: Public members are accessible from outside the class. They can be accessed by any code that has access to the class object. In many object-oriented languages, class members are public by default if no access specifier is explicitly specified.

class MyClass:
    def __init__(self):
        self.public_attribute = "Public attribute"

    def public_method(self):
        return "Public method"

obj = MyClass()
print(obj.public_attribute)  # Accessing public attribute
print(obj.public_method())    # Accessing public method
Public attribute
Public method
  1. Private: Private members are only accessible within the class in which they are defined. They cannot be accessed or modified from outside the class, not even by its subclasses. In many languages, private members are indicated by prefixing the member name with an underscore (_). However, it’s more of a convention than enforced by the language itself.

Example (Python):

class MyClass:
    def __init__(self):
        self.public_attribute = "Public attribute"

    def public_method(self):
        return "Public method"

obj = MyClass()
print(obj.public_attribute)  # Accessing public attribute
print(obj.public_method())    # Accessing public method
Public attribute
Public method
  1. Protected: Protected members are accessible within the class in which they are defined and its subclasses. They cannot be accessed from outside the class hierarchy. In many languages, protected members are indicated by prefixing the member name with a double underscore (__). However, like private members, it’s more of a convention than strictly enforced.

Example (Python):

class MyClass:
    def __init__(self):
        self.__protected_attribute = "Protected attribute"

    def __protected_method(self):
        return "Protected method"

class Subclass(MyClass):
    def display_protected(self):
        # Accessing protected attribute and method from subclass
        print(self.__protected_attribute)
        print(self.__protected_method())

obj = Subclass()
obj.display_protected()  # Accessing protected attribute and method from subclass
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[25], line 15
     12         print(self.__protected_method())
     14 obj = Subclass()
---> 15 obj.display_protected()  # Accessing protected attribute and method from subclass

Cell In[25], line 11, in Subclass.display_protected(self)
      9 def display_protected(self):
     10     # Accessing protected attribute and method from subclass
---> 11     print(self.__protected_attribute)
     12     print(self.__protected_method())

AttributeError: 'Subclass' object has no attribute '_Subclass__protected_attribute'

Access specifiers help in encapsulating the implementation details of a class, promoting data hiding, and preventing unintended access or modification of class members. However, in many languages, like Python, they are more about convention and developer discipline rather than strict enforcement by the language itself.

Name Mangling:#

Name mangling is a technique used in some object-oriented programming languages, such as Python, to change the name of variables and methods in a way that makes them less likely to be accidentally overridden in subclasses.

In Python, name mangling is achieved by prefixing the name of a variable or method with double underscores (__). When name mangling is applied, the interpreter automatically renames the variable or method by adding the class name as a prefix.

Here’s how name mangling works in Python:

class MyClass:
    def __init__(self):
        self.__private_variable = 10

    def __private_method(self):
        return "Private method"

# Accessing private variable and method with name mangling
obj = MyClass()
print(obj._MyClass__private_variable)  # Output: 10
print(obj._MyClass__private_method())  # Output: Private method
10
Private method

In this example:

  • The variables __private_variable and __private_method are private to the MyClass class due to name mangling.

  • When name mangling is applied, the variable and method names are prefixed with _MyClass, where MyClass is the name of the class where they are defined.

  • To access a mangled variable or method from outside the class, the name must be prefixed with _MyClass.

  • Name mangling helps prevent accidental access or overriding of private members in subclasses, as the mangled names are less likely to collide with names in subclasses.

It’s important to note that name mangling is not intended for security purposes, as it’s still possible to access mangled names with the proper prefix. Instead, it’s primarily used to avoid accidental conflicts and to clearly indicate that a variable or method is intended to be private to the class.

Inner/Nested Class:#

In object-oriented programming, an inner class, also known as a nested class, is a class defined within another class. Inner classes are a way to logically group classes together when one class should only be used in the context of another class.

Here’s an example of an inner class in Python:

class OuterClass:
    def __init__(self):
        self.outer_var = "Outer variable"

    def outer_method(self):
        print("Outer method")

    class InnerClass:
        def __init__(self):
            self.inner_var = "Inner variable"

        def inner_method(self):
            print("Inner method")

# Creating an instance of the outer class
outer_obj = OuterClass()

# Accessing outer class variable and method
print(outer_obj.outer_var)  # Output: Outer variable
outer_obj.outer_method()    # Output: Outer method

# Creating an instance of the inner class
inner_obj = outer_obj.InnerClass()

# Accessing inner class variable and method
print(inner_obj.inner_var)  # Output: Inner variable
inner_obj.inner_method()    # Output: Inner method
Outer variable
Outer method
Inner variable
Inner method

In this example:

  • InnerClass is defined within OuterClass.

  • InnerClass can access the attributes and methods of OuterClass because it is defined within its scope.

  • InnerClass can also be instantiated from an instance of OuterClass.

  • InnerClass can access its own attributes and methods independently of OuterClass.

Inner classes are useful for organizing and encapsulating related classes, especially when the inner class is closely related to the outer class and not needed outside of it. They can also be used to logically group related classes together, improving code organization and readability. However, inner classes should be used judiciously, and their use should be justified based on the specific requirements of the application.

Association, aggregation, and composition are concepts in object-oriented programming (OOP) that describe relationships between classes and objects.

Association: Association represents a relationship between two or more classes where objects of one class are aware of objects of another class. It’s a loosely coupled relationship where each class remains independent of the other. Associations can be one-to-one, one-to-many, or many-to-many.

Example: A Teacher class and a Student class have an association, where a teacher teaches multiple students. The Teacher class may have a list of students as an attribute.

Aggregation: Aggregation is a form of association where one class (the whole) contains references to other classes (the parts) but does not own them. It represents a “has-a” relationship where the contained objects can exist independently of the container. Aggregation implies a weaker relationship compared to composition.

Example: A Department class contains references to multiple Employee objects. The employees exist independently of the department and can belong to other departments.

Composition: Composition is a stronger form of aggregation where the contained objects are owned by the container and have their lifecycles managed by the container. It represents a “contains-a” relationship, implying strong ownership and responsibility. When the container is destroyed, its contained objects are also destroyed.

Example: A House class contains references to multiple Room objects. The rooms are part of the house and cannot exist independently. When the house is destroyed, all its rooms are also destroyed.

In summary:

  • Association represents a relationship between classes where objects of one class are aware of objects of another class.

  • Aggregation represents a “has-a” relationship where one class contains references to other classes, but the contained objects can exist independently.

  • Composition represents a “contains-a” relationship where one class owns and manages the lifecycle of the contained objects.