Destructor with circular reference


    class A:
        def __init__(self, bb):
            self.b = bb
    class B:
        def __init__(self):
            self. a = A(self)
        def __del__(self):
            print ("die")

    def fun():
        b = B()

    fun() # die

    '''
    when fun() is called, it creates an instance of class B which passes itself to class A, which then sets a reference to class B and resulting in a circular reference.
    Generally, Python's garbage collector which is used to detect these types of cyclic references would remove it but in this example the use of custom destructor marks this item as “uncollectable”.
    Simply, it doesn't know the order in which to destroy the objects, so it leaves them.
    Therefore, if your instances are involved in circular references they will live in memory for as long as the application run.
    '''
        

Accessing Parents Variable Without Invoking Parents __init__


    class A:
        def __init__(self, n = 'Rahul'):
            self.name = n
    class B(A):
        def __init__(self, roll):
            self.roll = roll
    object = B(23)
    print (object.name) # AttributeError: 'B' object has no attribute 'name'
    ''' need to invoke __init__() of A in B'
        

Parent's private variable


    class C(object):
        def __init__(self):
            self.c = 21
            self.__d = 42
    class D(C):
        def __init__(self):
            self.e = 84
            C.__init__(self)
    object1 = D()
    print(object1.d) # AttributeError: type object 'D' has no attribute 'd'
    ''' Parent's private variables are not accessible to Child '''
        

Inheritance precedence


    class A():
        def __init__(self):
            self.name = "A"
        def reply(self):
            print("Hi! I am A")
    class B():
        def __init__(self):
            self.name = "B"
        def reply(self):
            print("Hi! I am B")
    class C(A, B):
        def __init__(self):
            B.__init__(self)
            A.__init__(self)
    c = C()
    c.reply() # Hi! I am A
    ''' inheritance starts from left to right '''
    ''' class C(B, A) would have executed B's reply() '''
    ''' doesn't matter on the sequence of init calls '''
        

Protected and Private members


    class Base():
        def __init__(self):
            self._name = "abc"
            self.__aadhar = 1234
    class Derived(Base):
        def __init__(self):
            Base.__init__(self)
            print(self._name)      #3 abc
            print(self.__aadhar)   #4 AttributeError: 'Derived' object has no attribute '_Derived__aadhar'
    b = Base()
    print(b._name)                 #1 abc
    print(b.__aadhar)              #2 AttributeError: 'Base' object has no attribute '__aadhar'
    d = Derived()
    print(d._name)                 #5 abc
    print(d.__aadhar)              #6 AttributeError: 'Derived' object has no attribute '__aadhar'
    print(b._Base__aadhar)         #7 1234: This way of accessing private members of a class is known as "name mangling".
        

Class Variable Value Change


    class CSStudent:
        stream = 'cse' # Class Variable
        def __init__(self, name):
            self.name = name # Instance Variable
    a = CSStudent('abc')
    b = CSStudent('bcd')

    # changing class variable value of an object
    a.stream = "ece"
    print(CSStudent.stream) # "cse"
    print(a.stream) # "ece"
    print(b.stream) # "cse"

    # changing value of class variable of all the objects at once
    CSStudent.steam = "it"
    print(a.stream) # "ece" - as it was explicitly changed earlier
    print(b.stream) # "it"
        

What is name mangling?

There is a way of accessing private members using the object

    class Student:
        __school = "abc"
    s1 = Student
    print(s1.__school) # error
    print(s1._Student__school) # "abc" # <obj_name>._<class_name>__<variable_name>
        

__slots__ in Python

  • __slots__ is used to limit the attributes that an object can have, saving memory and potentially speeding attribute access.


    class SlotsTest:
        __slots__ = ['x', 'y']
        def __init__(self, x, y):
            self.x = x
            self.y = y

    obj = SlotsTest(2, 3)
    obj.x = 5  # Works fine
    obj.z = 4  # AttributeError: 'SlotsTest' object has no attribute 'z'
        

Abstract Base Class

  • Abstract base classes define methods that must be implemented by any subclass
  • They ensure a certain degree of conformity in how different classes are designed and interacted with.

  from abc import ABC, abstractmethod

  class AbstractClass(ABC):
      @abstractmethod
      def my_method(self):
          pass

  class ConcreteClass(AbstractClass):
      def my_method(self):
          print("Implementation of my_method")

  obj = ConcreteClass()
  obj.my_method()  # Output: Implementation of my_method
        

Method Resolution Order (MRO)

  • Method Resolution Order (MRO) determines the order in which Python looks for a method in a hierarchy of classes.
  • It uses the C3 linearization algorithm.
  • It is defined by the __mro__ attribute or the mro() method.
  • Understanding MRO is crucial for knowing where methods are inherited from, especially in complex class hierarchies.

    class A:
        def who(self):
            print("A")

    class B(A):
        pass

    class C(A):
        def who(self):
            print("C")

    class D(B, C):
        pass

    obj = D()
    obj.who()  # Prints "C" because of the order in MRO
    print(D.mro())  # Shows the order: [D, B, C, A, object]
        

__call__ in Python

  • __call__ allows an instance of a class to be called as a function.

    class Product:
        def __init__(self, name, price):
            self.name = name
            self.price = price

        def __call__(self, x):
            print(f"{self.name} costs {self.price * x}")

    p = Product("coffee", 2)
    p(3)  # Output: coffee costs 6

        

__new__ vs __init__ in Python

  • __new__ is a static method that is responsible for creating a new instance of a class.
  • __init__ is an instance method responsible for initializing the object after its creation.

        class MyClass:
            def __new__(cls):
                print("Creating instance")
                instance = super().__new__(cls)
                return instance

            def __init__(self):
                print("Initializing instance")

        obj = MyClass()             # Creating instance \n Initializing instance