Python Object Oriented Programming
...


Python Magic Methods:
...

Magic methods in Python are the special methods that start and end with the double underscores. They are also called dunder methods. Magic methods are not meant to be invoked directly by you, but the invocation happens internally from the class on a certain action.

Category Methods
Arithmetic operators __add__, __sub__, __mul__, __truediv__, __floordiv__, __mod__, __divmod__, __pow__, __radd__, __rsub__, __rmul__, __rtruediv__, __rfloordiv__, __rmod__, __rdivmod__, __rpow__, __iadd__, __isub__, __imul__, __itruediv__, __ifloordiv__, __imod__, __ipow__, __neg__, __pos__, __abs__, __round__, __floor__, __ceil__.
Bitwise Operations __and__, __invert__, __or__, __xor__, __rxor__
Comparison Operations __and__, __or__, __xor__, __invert__, __lshift__, __rshift__
Type Conversion __bool__, __int__, __float__, __complex__, __index__, __bytes__, __str__, __repr__
Container methods __len__, __getitem__, __setitem__, __delitem__, __contains__, __iter__, __next__, __reversed__, __missing__, __call__
Attribute methods __getattr__, __getattribute__, __setattr__, __delattr__
Other special methods __enter__, __exit__, __hash__, __new__, __init__, __del__, __call__, __dir__, __format__, __getnewargs__, __subclasshook__, __instancecheck__, __subclasscheck__, __prepare__, __annotations__, __locals__, __globals__, __qualname__, __name__, __module__, __doc__, __class__, __init_subclass__, __sizeof__, __trunc__, __floor_division__, __bit_length__, __conjugate__, __real__, __imag__, __complex__

Descriptions :
...

  • __abs__(self) - used to return the absolute value of an object.
  • __add__(self, other) - used to define the behavior of the addition operator (+) for an object.
  • __and__(self, other) - used to define the behavior of the bitwise AND operator (&) for an object.
  • __bool__(self) - used to return the boolean value of an object.
  • __ceil__(self) - used to return the ceiling value of an object.
  • __class__(self) - used to return the class of an object.
  • __delattr__(self, name) - used to delete an attribute from an object.
  • __dir__(self) - used to return a list of valid attributes for an object.
  • __divmod__(self, other) - used to define the behavior of the divmod() function for an object.
  • __doc - used to store the documentation string for a class or function.
  • __eq__(self, other) - used to define the behavior of the equality operator (==) for an object.
  • __float__(self) - used to convert an object to a floating-point number.
  • __floor__(self) - used to return the floor value of an object.
  • __floordiv__(self, other) - used to define the behavior of the floor division operator (//) for an object.
  • __format__(self, format_spec) - used to define the behavior of the str.format() method for an object.
  • __ge__(self, other) - used to define the behavior of the greater than or equal to operator (>=) for an object.
  • __getattribute__(self, name) - used to get the value of an attribute from an object.
  • __getnewargs__(self) - used to return the arguments for the object constructor.
  • __gt__(self, other) - used to define the behavior of the greater than operator (>) for an object.
  • __hash__(self) - used to return the hash value of an object.
  • __index__(self) - used to return the integer value of an object.
  • __init__(self) - used to initialize an object.
  • __init_subclass(cls) - used to initialize a subclass.
  • __int__(self) - used to convert an object to an integer.
  • __invert__(self) - used to define the behavior of the bitwise NOT operator (~) for an object.
  • __le__(self, other) - used to define the behavior of the less than or equal to operator (<=) for an object.
  • __lshift__(self, other) - used to define the behavior of the left shift operator (<<) for an object.
  • __lt__(self, other) - used to define the behavior of the less than operator (<) for an object.
  • __mod__(self, other) - used to define the behavior of the modulo operator (%) for an object.
  • __mul__(self, other) - used to define the behavior of the multiplication operator (*) for an object.
  • __ne__(self, other) - used to define the behavior of the not equal operator (!=) for an object.
  • __neg__(self) - used to define the behavior of the negation operator (-) for an object.
  • __new(cls, *args, kwargs) - used to create a new instance of a class. This method is called before init() and is responsible for creating and returning the new object.
  • __or__(self, other) - used to define the behavior of the bitwise OR operator (|) for an object.
  • __pos__(self) - used to define the behavior of the unary plus operator (+) for an object.
  • __pow__(self, other[, modulo]) - used to define the behavior of the exponent
  • __rsub__(self, other) - used to define the behavior of the subtraction operator (-) when the object is on the right-hand side.
  • __rtruediv__(self, other) - used to define the behavior of the true division operator (/) when the object is on the right-hand side.
  • __rxor__(self, other) - used to define the behavior of the bitwise XOR operator (^) when the object is on the right-hand side.
  • __setattr__(self, name, value) - used to set the value of an attribute on an object.
  • __sizeof__(self) - used to return the size of an object in bytes.
  • __str__(self) - used to return a string representation of an object.
  • __sub__(self, other) - used to define the behavior of the subtraction operator (-) for an object.
  • __subclasshook__(cls, subclass) - used to customize the behavior of the issubclass() built-in function.
  • __truediv__(self, other) - used to define the behavior of the true division operator (/) for an object.
  • __trunc__(self) - used to return the truncated integer value of a float or decimal number.
  • __xor__(self, other) - used to define the behavior of the bitwise XOR operator (^) for an object.
  • __bit_length__(self) - used to return the number of bits required to represent an integer in binary.
  • __conjugate__(self) - used to return the complex conjugate of a complex number.
  • __denominator__(self) - used to return the denominator of a rational number.
  • __from_bytes(bytes, byteorder, *, signed=False) - used to convert a sequence of bytes into an integer.
  • __imag__(self) - used to return the imaginary part of a complex number.
  • __numerator__(self) - used to return the numerator of a rational number.
  • __real__(self) - used to return the real part of a complex number.
  • __to_bytes__(self, length, byteorder, *, signed=False) - used to convert an integer into a sequence of bytes.

Diffrences between init and new :
...

Similarities :
...

  • Both of them are called/invoked during the creation of the instance.

Differences :
...

new init
1 Called before init Called after new
2 Accepts a type as the first argument Accepts an instance as the first argument
3 Is supposed to return an instance of the type received Is not supposed to return anything
4 Used to control instance creation Used to initialize instance variables

Talking about the first point. __new__ is called when the instance is first created. This happens before the initialization of the class.

also its important to mention that the first argument to __init__ is always self This self is the instance of the class. self is what __new__ returns.

Coming to the third point, __new__ is supposed to return an instance of the class. Note that if __new__ does not returns anything, __init__ is not called.

Which one of them is a constructor?
...

If you are coming from another language, you might be surprised that there are two similar things doing the same kind of work. Most languages have something called a constructor.

In Python, that concept is broken down into constructor and initializer. And __new__ is the constructor and __init__ is the initializer.

Please note that __new__ is implicit. Meaning that if you don’t actually need to modify the creation of an instance of the class, you don’t need to have a new method.

instance variables are local to an instance. So anything you are doing in init is local to that instance only. But anything you are doing in new will be affecting anything created for that type.

Consider this example:

class Demo:
    def __new__(cls, *args):
        print("__new__ called")
        return object.__new__(cls)

    def __init__(self):
        print("__init__ called")

d = Demo()

This is the simplest example of both __new__ and __init__ in action. If you save the above code in a file and run it, you’d see something like this:

$ python3 in_it.py 
__new__ called
__init__ called

As you can see, the new method is called first and then execution is passed to the init method.

Use Cases :
...

Use case for __new__ :

One of the best use cases we can take an example of is when creating a Singleton. As we know, Singleton ensures a class only has one instance and provides a global point of access to it.

Some of the places singleton are used is in game programming where there is only one instance of the player. Doesn’t matter how many instances you create, you’ll end up having only one.

Let’s see how to achieve similar behavior in Python:

class Singleton:
    __instance = None
    
    def __new__(cls):
        if cls.__instance is None:
            print("creating...")
            cls.__instance = object.__new__(cls)
        return cls.__instance

s1 = Singleton()
s2 = Singleton()

print(s1)
print(s2)

Output:

$ python3 singleton.py 
creating...
<__main__.Singleton object at 0x7f943301d350>
<__main__.Singleton object at 0x7f943301d350>

As you can see, creating… is printed only once. they both point to the same memory location. In my case, it’s 0x7f943301d350.

You might be guessing that can’t we do the same thing with __init__? No! That’s because __init__ does not return anything. We’ll see in the next section what __init__ is well suited for.

Use case for __init__ :

As we have already seen previously. init is there to initialize an instance variable. These instance variables can later be used in different methods of the instance.

Here I’ll demonstrate one such example:

class Window(QWidget):
   def __init__(self, parent = None):
      super(Window, self).__init__(parent)
      self.resize(200, 100)
      self.setWindowTitle("My App")

When initializing UI objects, you can set how wide or long the window could be. You can also read preferences from a file and apply that during the initialization phase of an application. Setting the window title could be another example.

Conclusion :
...

  • In most cases, you don’t need
  • __new__ is called before __init__.
  • __new__ returns a instance of class.
  • __init__ receives the instances of the class returned by __new__.
  • Use __init__ to initilize value.

The same concept can be used to answer the question of abstraction vs encapsulation.

UML Diagrams :
...

The unified modeling language (UML) is a general-purpose modeling language that is intended to provide a standard way to visualize the design of a system.(It's worth mentioning that when using Pycharm, the IDE generates a UML diagram automatically!)
Although, in software engineering, most practitioners do not use UML, but instead produce informal hand drawn diagrams; these diagrams, however, often include elements from UML. 

OOP introduction :
...

#class introduction
class Car:

    # The __init__ method is used to initialize the attributes of the class.
    def __init__(self, make, model, year, color):
        self.make = make
        self.model = model
        self.year = year
        self.color = color

    # This method is used to print a message indicating that the car is driving.
    def drive(self):
        print("This " + self.model + " is driving")

    # This method is used to print a message indicating that the car is stopped.
    def stop(self):
        print("This " + self.model + " is stopped")

    # This method is used to print the attributes of the Car object in a formatted string.
    def __str__(self):
        return f"\nmake: {self.make}\nmodel: {self.model}\nyear: {self.year}\ncolor: {self.color}"

    # This method is used to compare if two Car objects are equal by checking their attributes.
    def __eq__(self, other):
        if isinstance(other, Car):
            return (self.make == other.make) and (self.model == other.model) and (self.year == other.year) and (self.color == other.color)
        else:
            return False

The code defines a Car class that represents a car and has four attributes: make, model, year, and color.

The __init__ method is used to initialize these attributes, and the class has three methods: drive(), stop(), and __str__().

The drive() method prints a message indicating that the car is driving, and the stop() method prints a message indicating that the car is stopped.

The __str__() method returns a formatted string that displays the attributes of the car object.

The __eq__() method is used to compare if two Car objects are equal by checking their attributes. It returns True if all the attributes of two Car objects are equal and False otherwise.

car_1 = Car("Chevy","Corvette",2021,"blue")
car_2 = Car("Ford","Mustang",2022,"red")
print(car_2)
car_2.drive()
print(car_2 == car_1)

Inheritance :
...

#inheritance
class Car:

    # The __init__ method is used to initialize the attributes of the class.
    def __init__(self, make, model, year, color):
        self.make = make
        self.model = model
        self.year = year
        self.color = color

    # This method is used to print a message indicating that the car is driving.
    def drive(self):
        print("This " + self.model + " is driving")

    # This method is used to print a message indicating that the car is stopped.
    def stop(self):
        print("This " + self.model + " is stopped")

    # This method is used to print the attributes of the Car object in a formatted string.
    def __str__(self):
        return f"\nmake: {self.make}\nmodel: {self.model}\nyear: {self.year}\ncolor: {self.color}"

class PlaceHolder(Car):
    pass

class Truck(Car):

	def __init__(self,make,model,year,color,weight):
	    #super().__init__(self,make,model,year,color)
	    Car.__init__(self,make,model,year,color)
	    self.weight = weight

truck_1 = Truck("Tesla","Cyber truck",2024,"silver",5000)

print(truck_1)

The code defines a Car class that represents a car and has four attributes: make, model, year, and color. The class has three methods: drive(), stop(), and __str__().

The drive() method prints a message indicating that the car is driving, and the stop() method prints a message indicating that the car is stopped. The __str__() method returns a formatted string that displays the attributes of the car object.

There are two additional classes defined: PlaceHolder and Truck. The PlaceHolder class inherits from the Car class but does not define any additional attributes or methods.

The Truck class also inherits from the Car class and adds a new attribute called weight in the __init__ method. The Truck class initializes the make, model, year, and color attributes of the Car class by calling the __init__() method of the Car class using either super().__init__(self, make, model, year, color) or Car.__init__(self, make, model, year, color).

The Truck class instance truck_1 is created using the Truck class constructor, which takes in make, model, year, color, and weight attributes.

Finally, the print() function is used to print the truck_1 object, which calls the __str__() method of the Car class to display the attributes of the object.

Abstract Classes and Polymorphism :
...

from abc import ABC, abstractmethod

class Car(ABC):
    # define an abstract method that must be implemented by any concrete subclass
    @abstractmethod
    def start_engine(self):
        pass
    
    # define an initializer that sets the make and model of the car
    def __init__(self, make, model):
        self.make = make
        self.model = model

class SportsCar(Car):
    # provide a concrete implementation for the start_engine() method
    def start_engine(self):
        return "The {0} {1} engine is starting...".format(self.make, self.model)

class SedanCar(Car):
    # provide a different concrete implementation for the start_engine() method
    def start_engine(self):
        return "The {0} {1} engine is starting...".format(self.make, self.model)

# create a list of cars, including one sports car and one sedan car
cars = [SportsCar("Ferrari", "F430"), SedanCar("Toyota", "Camry")]

# call the start_engine() method for each car, which executes the appropriate implementation based on the type of car

The code defines an abstract base class Car that is inherited by two concrete subclasses: SportsCar and SedanCar. The Car class has one abstract method, start_engine(), which must be implemented by any concrete subclass that inherits from it.

The SportsCar and SedanCar classes both inherit from the Car class and provide a concrete implementation for the start_engine() method. The implementation uses string formatting to return a message indicating that the engine is starting for the given make and model of the car.

A list called cars is created that includes one instance of SportsCar and one instance of SedanCar. Finally, a loop iterates over the list of cars and calls the start_engine() method for each car, which executes the appropriate implementation based on the type of car.

This code demonstrates the concept of polymorphism, where objects of different types can be treated as if they are the same type, as long as they implement the same methods or have the same attributes. In this case, both SportsCar and SedanCar are treated as Car objects, so they can be added to the same list and looped over in the same way.

Encapsulation / Private variables :
...

The Car class is an example of encapsulation in Python. It demonstrates how to use private instance variables and getter methods to restrict direct access to class attributes.

class Car:
    def __init__(self, make, model, year):
        # Initialize private instance variables
        self.__make = make
        self.__model = model
        self.__year = year
        self.__speed = 0

    def get_make(self):
        # Getter method for private variable __make
        return self.__make

    def get_model(self):
        # Getter method for private variable __model
        return self.__model

    def get_year(self):
        # Getter method for private variable __year
        return self.__year

    def get_speed(self):
        # Getter method for private variable __speed
        return self.__speed

    def accelerate(self):
        # Increase the speed of the car by 10 km/h
        self.__speed += 10

    def brake(self):
        # Decrease the speed of the car by 10 km/h
        self.__speed -= 10

In this example, the __make, __model, __year, and __speed instance variables are marked as private by prefixing them with two underscores. Private variables can only be accessed within the class definition. To access these private variables, getter methods are defined that return the values of the private variables. This ensures that external code cannot directly modify the state of the object.

A Car object is created with the make "Honda", model "Accord", and year 2021. The car is accelerated twice and then braked once. The make, model, year, and speed of the car are printed using the getter methods.

# Create a Car object with make "Honda", model "Accord", and year 2021
car = Car("Honda", "Accord", 2021)

# Accelerate the car twice and then brake once
car.accelerate()
car.accelerate()
car.brake()

# Print the make, model, year, and speed of the car using the getter methods
print("Make:", car.get_make())
print("Model:", car.get_model())
print("Year:", car.get_year())
print("Speed:", car.get_speed(), "km/h")

Encapsulation / Protected :
...

In Python, protected variables are denoted with a single underscore prefix (e.g., _make). This indicates that the variable should not be accessed from outside the class, but can be accessed from within the class hierarchy.

In the following example, we define a Car class with two protected instance variables: _make and _model. The _start_engine method is also protected, which means it can only be accessed from within the class hierarchy.

Then, we define a SportCar subclass that inherits from Car. The race method of SportCar calls the _start_engine method from the parent Car class and prints a message indicating that the car is racing.

class Car:
    def __init__(self, make, model):
        self._make = make  # protected variable
        self._model = model  # protected variable

    def _start_engine(self):
        print(f"The {self._make} {self._model} engine is starting...")

class SportCar(Car):
    def race(self):
        self._start_engine()
        print("I'm racing now!")

We can create a SportCar object with make "Ferrari" and model "F430" and call the race method:

sport_car = SportCar("Ferrari", "F430")
sport_car.race()

Note that the protected variables _make and _model can be accessed from within the class hierarchy (i.e., from the Car and SportCar classes), but not from outside the class hierarchy. For example, the following line would raise an AttributeError:

print(sport_car._make)