1 year

Object-oriented programming


"Object-oriented programming (OOP) is a programming paradigm based on the concept of "objects", which are data structures that contain data, in the form of attributes, and code, in the form of functions known as methods. A distinguishing feature of objects is that an object's method can access and often modify the data attributes of the object with which they are associated (objects have a notion of "self"). In OO programming, computer programs are designed by making them out of objects that interact with one another."

Everything in Python is an object, so this shows that OOP is not just supported by Python, but it's a part of its very core.The two main players in OOP are objects and classes. Classes are used to create objects. When objects are created by a class, they inherit the class attributes and methods. 

class Rectangle:
  def __init__(self, side_a, side_b):             
     self.side_a = side_a                              
     self.side_b = side_b                              
  def area(self):                                         
     return self.side_a * self.side_b            
r1 = Rectangle(10, 4)                               
print(r1.side_a, r1.side_b) # 10 4            
print(r1.area()) # 40                               
r2 = Rectangle(7, 3)                               
print(r2.area()) # 21                              

It is actually an initializer, since it works on an already-created instance, and therefore it's called __init__. It's a magic method, which is run right after the object is created. Python objects also have a __new__ method, which is the actual constructor. When an object is created, the __init__  method is automatically run for us. 

OOP is all about code reuse. We define a class, we create instances, and those instances use methods that are defined only in the class.
They will behave differently according to how the instances have been set up by the initializer.

Inheritance and composition

Inheritance means that two objects are related by means of an Is-A type of relationship. On the other hand, composition means that two objects are related by means of a Has-A type of relationship. 

class Engine:                                                                             
   def start(self):                                                                         
   def stop(self):                                                                        
class ElectricEngine(Engine):       # Is-A Engine                     


class Car:                                                                                    
   engine_cls = Engine                                                                
   def __init__(self):                                                                    
      self.engine = self.engine_cls()   # Has-A Engine                 

The type of relationship between a car and its engine is a Has-A type. A car Has-A engine. This is called composition, and reflects the fact that objects can be made of many other objects. A car Has-A engine, gears, wheels, a frame, doors, seats, and so on. When designing OOP code, it is of vital importance to describe objects in this way so that we can use inheritance and composition correctly to structure our code in the best way.

class Book:
  def __init__(self, title, publisher, pages):
     self.title = title
     self.publisher = publisher
     self.pages = pages
class Ebook(Book):
  def __init__(self, title, publisher, pages, format_):
     super().__init__(title, publisher, pages)
     # Another way to do the same thing is:
     # super(Ebook, self).__init__(title, publisher, pages)
    self.format_ = format_

super is a function that returns a proxy object that delegates method calls to a parent or sibling class.

Multiple inheritance

A class can be derived from more than one base class in Python, similar to C++. This is called multiple inheritance.In multiple inheritance, the features of all the base classes are inherited into the derived class. The syntax for multiple inheritance is similar to single inheritance.

class Shape:
  geometric_type = 'Generic Shape'
  def area(self): # This acts as placeholder for the interface
      raise NotImplementedError
  def get_geometric_type(self):
      return self.geometric_type
class Plotter:
  def plot(self, ratio, topleft):
  # Imagine some nice plotting logic here...
     print('Plotting at {}, ratio {}.'.format(
  topleft, ratio))
class Polygon(Shape, Plotter):
  geometric_type = 'Polygon'

This technique is very popular in web frameworks such as Django, which provides special classes called mixins, whose capabilities you can just use out of the box. All you have to do is to define the desired mixin as one the base classes for your own, and that's it. 

Method resolution order

When multiple inheritance is involved, there are cases when it's not straightforward to predict what will be the next class that will be searched for if an attribute is not found. Python provides a way to always know the order in which classes are searched on attribute lookup: the Method Resolution Order (MRO).

The MRO is the order in which base classes are searched for a member during lookup. From version 2.3, Python uses an algorithm called C3, which guarantees monotonicity. In Python 2.2, new-style classes were introduced. The way you write a new-style class in Python 2.* is to define it with an explicit object base class. Classic classes were not explicitly inheriting from object and have been removed in Python 3. One of the differences between classic and new-style classes in Python 2.* is that new-style classes are searched with the new MRO.

class A:
   label = 'a'
class B(A):
   pass # was: label = 'b'
class C(A):
   label = 'c'
class D(B, C):
d = D()
print(d.label) # 'c'
print(d.__class__.mro()) # notice another way to get the MRO
# prints:
# [<class '__main__.D'>, <class '__main__.B'>,
# <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

So, we learn that the MRO is D-B-C-A-object, which means when we ask for d.label, we get 'c', which is correct. 

Static Methods
A static method is also a method that is bound to the class and not the object of the class.A static method can’t access or modify the class state. It is present in a class because it makes sense for the method to be present in class.

Class methods
A class method is a method that is bound to the class and not the object of the class. They have the access to the state of the class as it takes a class parameter that points to the class and not the object instance. It can modify a class state that would apply across all the instances of the class. For example, it can modify a class variable that will be applicable to all the instances.

class Point:
 def __init__(self, x, y):
 self.x = x
 self.y = y
 def from_tuple(cls, coords): # cls is Point
 return cls(*coords)
 def from_point(cls, point): # cls is Point
 return cls(point.x, point.y)
p = Point.from_tuple((3, 7))
print(p.x, p.y) # 3 7
q = Point.from_point(p)
print(q.x, q.y) # 3 7

We generally use class method to create factory methods. Factory methods return class objects ( similar to a constructor ) for different use cases. We generally use static methods to create utility functions.

Private methods and name mangling
If you have any background with languages like Java or C++, then you know they allow the programmer to assign a privacy status to attributes (both data and methods). Each language has its own slightly different flavor for this, but the gist is that public attributes are accessible from any point in the code, while private ones are accessible only within the scope they are defined in. In Python, there is no such thing. Everything is public; therefore, we rely on conventions and on a mechanism called name mangling.

Name mangling is invoked when you are in a class definition and use __any_name or __any_name_, that is, two (or more) leading underscores and at most one trailing underscore.Name mangling means that any attribute name that has at least two leading underscores and at most one trailing underscore, such as __my_attr, is replaced with a name that includes an underscore and the class name before the actual name, such as _ClassName__my_attr.

PEP 8, the Python standard library style guide, currently says:
If your class is intended to be subclassed, and you have attributes that you do not want subclasses to use, consider naming them with double leading underscores and no trailing underscores.

*Note that only the simple class name is used in the mangled name, so if a subclass chooses both the same class name and attribute name, you can still get name collisions.

*Name mangling can make certain uses, such as debugging and __getattr__() , less convenient. However the name mangling algorithm is well documented and easy to perform manually.

The property decorator
In Python, getters and setters are not the same as those in other object-oriented programming languages. Basically, the main purpose of using getters and setters in object-oriented programs is to ensure data encapsulation. Private variables in python are not actually hidden fields like in other object oriented languages. Getters and Setters in python are often used when: 

-We use getters & setters to add validation logic around getting and setting a value. 

-To avoid direct access of a class field i.e. private variables cannot be accessed directly or modified by external user. 

class Person:
 def __init__(self, age):
 self._age = age
 def age(self):
     return self._age
 def age(self, age):
     if 18 <= age <= 99:
        self._age = age
       raise ValueError('Age must be within [18, 99]')
person = Person(39)
print(person.age)      # 39 - Notice we access as data attribute
person.age = 42        # Notice we access as data attribute
print(person.age)      # 42
person.age = 100       # ValueError: Age must be within [18, 99]

The property decorator also allows for read-only data (no setter) and for special actions when the attribute is deleted.

The word polymorphism comes from the Greek polys (many, much) and morphē (form, shape), and its meaning is the provision of a single interface for entities of different types.

In Python, things are different. Polymorphism is implicit, nothing prevents you from calling a method on an object; therefore, technically, there is no need to implement interfaces or other patterns.
There is a special kind of polymorphism called ad hoc polymorphism, operator overloading. This is the ability of an operator to change shape, according to the type of data it is fed.
Like in other programming languages, the child classes in Python also inherit methods and attributes from the parent class. We can redefine certain methods and attributes specifically to fit the child class, which is known as Method Overriding. Polymorphism also allows Python programmers to simply use the interface (methods and properties) exposed from an object rather than having to check which class it was instantiated from. This allows the code to be more compact and feel more natural.

Data classes

Introduced in Python 3.7 by PEP557 (https://www.python.org/dev/peps/pep-0557/), they can be described as mutable named tuples with defaults. This is especially useful when your data class must be hashable; because this requires a __hash__ method as well as an __eq__ method. 

from dataclasses import dataclass

class Body:
   '''Class to represent a physical body.'''
   name: str
   mass: float = 0. # Kg
   speed: float = 1. # m/s
   def kinetic_energy(self) -> float:
     return (self.mass * self.speed ** 2) / 2
body = Body('Ball', 19, 3.1415)
print(body.kinetic_energy()) # 93.755711375 Joule
print(body) # Body(name='Ball', mass=19, speed=3.1415)  

dataclasses has a special method called __post_init__ .This method is called right after the __init__ method is called.

Using dataclasses, you can create objects that are read-only. All you have to do is set the frozen argument to True inside the @dataclass decorator.


When you do this, you prevent anyone from modifying the values of the attributes once the object is instantiated. If you try to set a frozen object’s attribute to a new value, a FrozenInstanceError error will be raised.

By default, dataclasses implements __eq__ . To allow the other types of comparison (__lt__ (less than), __le__ (less or equal), __gt__ (greater than) and __ge__ (greater or equal)), we have to set the order argument to True in [email protected] decorator.

Writing a custom iterator

Iterable: An object is said to be iterable if it's capable of returning its members one at a time. Lists, tuples, strings, and dictionaries are all iterables. Custom objects that define either of the __iter__ or __getitem__ methods are also iterables.

Iterator: An object is said to be an iterator if it represents a stream of data. A custom iterator is required to provide an implementation for __iter__ that returns the object itself, and implementation for __next__ that returns the next item of the data stream until the stream is exhausted, at which point all successive calls to __next__ simply raise the StopIteration exception. Built-in functions, such as iter and next, are mapped to call __iter__ and __next__ on an object, behind the scenes.

class OddEven:
 def __init__(self, data):
    self._data = data
    self.indexes = (list(range(0, len(data), 2)) +  list(range(1, len(data), 2)))
 def __iter__(self):
    return self
 def __next__(self):
    if self.indexes:
       return self._data[self.indexes.pop(0)]
    raise StopIteration

we needed to provide an implementation for __iter__ that returned the object itself, and then one for __next__.