Python: OOP and Class

Python supports object-oriented programming (OOP) paradigm. In OOP, everything is an object, and objects have attributes (properties) and methods (functions) that can be accessed and manipulated.

Classes in Python are the building blocks of object-oriented programming. A class is a blueprint for creating objects that have similar attributes and behaviors. An object is an instance of a class, and it has its own set of attributes and behaviors.

In this blog post, we will explore Python classes and object-oriented programming in detail.

Defining a class in Python

To define a class in Python, we use the class keyword followed by the name of the class. Here’s an example:

class Person:
  pass

In this example, we have defined a Person class that doesn’t do anything yet. We have used the pass keyword to indicate that the class is empty.

Creating objects from a class (Instantiation)

To create an instance of a class in Python, you call the class as if it were a function, passing any required arguments to the __init__ method.

Once we have defined a class, we can create objects from it. To create an object, we use the class name followed by parentheses. Here’s an example:

person1 = Person()
person2 = Person()

In this example, we have created two objects (person1 and person2 ) from the Person class. Both objects have the same attributes and behaviors as the Person class.

Lets define an __init__ method:

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age

person1 = Person("Sachin", 30)
person2 = Person("Virat", 25)

In this example, we define a Person class with an __init__ method that takes a name and age parameter, and initializes instance variables with the same names. We then create two instances (objects) of the Person class, passing in different values for the name and age parameters.

Adding attributes to a class

In Python, we can add attributes to a class using the __init__ method. The __init__ method is a special method that is called when an object is created. Here’s an example:

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age

person1 = Person("Sachin", 25)
person2 = Person("Virat", 30)

print(person1.name)  # Output: Sachin
print(person2.age)   # Output: 30

In this example, we have added two attributes (name and age) to the Person class using the __init__ method. When we create an object from the Person class, we pass values for these attributes as arguments. The self parameter is a reference to the object that is being created.

Adding methods to a class

In Python, we can add methods to a class just like we add functions to a module. Methods are functions that belong to a class, and they can access the attributes of the class. Here’s an example:

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age

  def introduce(self):
    print(f"My name is {self.name} and I am {self.age} years old.")

person1 = Person("Sachin", 25)
person1.introduce()  # Output: My name is Sachin and I am 25 years old.

In this example, we have added a introduce method to the Person class. The method accesses the name and age attributes of the class using the self parameter.

Class Variables

In addition to instance variables, you can also define class variables in Python classes. Class variables are shared among all instances of the class and can be accessed using either the class name or an instance of the class. Here’s an example:

class Person:
  count = 0

  def __init__(self, name, age):
    self.name = name
    self.age = age
    Person.count += 1

person1 = Person("Sachin", 30)
person2 = Person("Virat", 25)
print(Person.count)  # Output: 2

In this example, we define a count class variable that is initialized to 0 . We increment this variable each time a new instance of the Person class is created in the __init__ method. We can access the count variable using either the class name Person or an instance of the class.

Static Methods and Class Methods

In addition to regular instance methods, Python also allows you to define static methods and class methods in a class.

A static method is a method that belongs to the class rather than any particular instance of the class. It can be called using the class name or an instance of the class, but it doesn’t have access to any instance variables. Here’s an example:

class Calculator:
  @staticmethod
  def add(x, y):
    return x + y

print(Calculator.add(2, 3))  # Output: 5

In this example, we define a Calculator class with a static method add that takes two parameters x and y and returns their sum. We can call this method using either the class name Calculator or an instance of the class.

A class method is a method that belongs to the class and has access to class variables, but not instance variables. It’s defined using the @classmethod decorator. Here’s an example:

class Person:
  count = 0

  def __init__(self, name, age):
    self.name = name
      self.age = age
      Person.count += 1

  @classmethod
  def get_count(cls):
     return cls.count

person1 = Person("Sachin", 30)
person2 = Person("Virat", 25)
print(Person.get_count())  # Output: 2

In this example, we define a get_count class method that returns the value of the count class variable. We use the @classmethod decorator to indicate that this is a class method. We can call this method using either the class name Person or an instance of the class.

Magic/Dunder Methods

Python classes allow you to define special methods, known as magic methods or dunder (short for “double underscore”) methods, that are called in response to certain operations. These methods are surrounded by double underscores on both sides of the method name, like __init__.

For example, the __str__ method is called when an instance of a class is printed using the print function. Here’s an example:

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age

  def __str__(self):
    return f"Name: {self.name}, Age: {self.age}"

person1 = Person("Sachin", 30)
print(person1)  # Output: Name: Sachin, Age: 30

In this example, we define a Person class with an __init__ method that takes a name and age parameter, and initializes instance variables with the same names. We then define a __str__ method that returns a string representation of the instance, and includes the values of the name and age instance variables. We then create an instance of the Person class and print it using the print function.

Other commonly used magic methods include:

  • __repr__(self): returns a string that can be used to recreate the object.
  • __len__(self): returns the length of the object.
  • __add__(self, other): defines behavior for the + operator.
  • __eq__(self, other): defines behavior for the == operator.

Property

In Python, a class can have attributes that are accessed using dot notation, like my_object.my_attribute. However, sometimes you want to have more control over how an attribute is accessed or modified. This is where properties come in.

A property is a special kind of attribute that is defined using a decorator. When the attribute is accessed, the property’s getter method is called, and when it is modified, the property’s setter method is called. This allows you to define custom behavior for getting and setting the attribute.

Here’s an example:

class Person:
  def __init__(self, name, age):
    self._name = name
    self._age = age

  @property
  def name(self):
    return self._name

  @name.setter
  def name(self, value):
    if not isinstance(value, str):
      raise TypeError("Name must be a string")
    self._name = value

  @property
  def age(self):
    return self._age

  @age.setter
  def age(self, value):
    if not isinstance(value, int):
      raise TypeError("Age must be an integer")
    if value < 0 or value > 150:
      raise ValueError("Age must be between 0 and 150")
    self._age = value

In this example, we define a Person class with name and age instance variables. We then define name and age properties using the @property decorator. We also define setter methods for each property using the @name.setter and @age.setter decorators.

The getter methods simply return the value of the corresponding instance variable, while the setter methods perform some validation before setting the value of the instance variable. For example, the name setter method checks that the new value is a string before setting it.

Here’s how you would use this class:

person = Person("Sachin", 30)
print(person.name)  # Output: Sachin
print(person.age)   # Output: 30

person.name = "Virat"
person.age = 25

print(person.name)  # Output: Virat
print(person.age)   # Output: 25

person.name = 42  # Raises TypeError
person.age = -1   # Raises ValueError

In this example, we create an instance of the Person class with the name “Sachin” and age 30. We then access the name and age properties using dot notation. We also set the name and age properties to new values.

Note that when we set the name property to an integer value, a TypeError is raised because the name setter method checks that the value is a string. Similarly, when we set the age property to a negative value, a ValueError is raised because the age setter method checks that the value is between 0 and 150.

Properties are a powerful tool in Python that allow you to define custom behavior for accessing and modifying attributes. They can be used to enforce validation rules, perform calculations, or provide a more convenient interface for working with objects.

Inheritance

Inheritance is a key feature of object-oriented programming that allows us to create new classes based on existing ones. The new class (subclass) inherits the attributes and methods of the existing class (superclass). Here’s an example:

class Animal:
  def __init__(self, name):
    self.name = name

  def speak(self):
    pass

class Dog(Animal):
  def speak(self):
    return "Woof!"

class Cat(Animal):
  pass

In this example, we have a superclass Animal with an __init__ method that initializes the name attribute, and a speak method that doesn’t do anything. We also have two subclasses Dog and Cat that inherit from the Animal class.

The Dog class overrides the speak method with a Woof! string, while the Cat class does not override the speak method, so it inherits the speak method from the Animal class.

We can create objects from the Dog and Cat classes just like we would create objects from the Person class:

dog = Dog("Fido")
cat = Cat("Whiskers")

print(dog.name)  # Output: Fido
print(dog.speak())  # Output: Woof!
print(cat.name)  # Output: Whiskers
print(cat.speak())  # Output: None

In this example, we have created a Dog object dog with the name attribute "Fido", and a Cat object cat with the name attribute "Whiskers". We can access the name attribute of each object using the dot notation (dog.name, cat.name).

We can also call the speak method of the Dog object dog using the dot notation (dog.speak()), which returns the string "Woof!". However, when we call the speak method of the Cat object cat using the dot notation (cat.speak()), it returns None, because the Cat class doesn’t override the speak method, which is not implemented in the base class Animal.

Encapsulation

Encapsulation is another key feature of object-oriented programming that allows us to hide the internal details of a class from the outside world. We can do this by making some attributes and methods of a class private, which means they can only be accessed from within the class.

In Python, we can make attributes and methods private by prefixing their names with two underscores (__) (pronounced dunder / magic). Here’s an example:

class Car:
  def __init__(self, make, model, year):
    self.__make = make
    self.__model = model
    self.__year = year

  def get_make(self):
    return self.__make

  def get_model(self):
    return self.__model

  def get_year(self):
    return self.__year

car = Car("Toyota", "Camry", 2022)

print(car.get_make())  # Output: Toyota
print(car.get_model())  # Output: Camry
print(car.get_year())  # Output: 2022
print(car.__make)  # Raises an AttributeError: 'Car' object has no attribute '__make'

In this example, we have defined a Car class with three private attributes (__make, __model, __year) and three public methods (get_make, get_model, get_year) that return the values of these attributes. The get_make, get_model, and get_year methods can be accessed from outside the class using the dot notation (car.get_make(), car.get_model(), car.get_year()).

However, if we try to access the private __make attribute of the Car object car using the dot notation (car.__make), we get an AttributeError because the __make attribute is not accessible from outside the class.

Though private attribute / method can be access using the following syntax

# <instance>._<class name><attribute_name>
print(car._Car__make) # Output: Toyota

Polymorphism

Polymorphism is the ability of objects of different classes to be used interchangeably. In other words, if two objects have the same method or attribute name, they can be used in the same way, even if they belong to different classes.

For example, let’s say we have two classes Circle and Square, both of which have a get_area method that returns the area of the shape. We can write a function that takes an object of either class as an argument and calls its get_area method:

class Circle:
  def __init__(self, radius):
    self.radius = radius
    
  def get_area(self):
    return 3.14 * self.radius ** 2

class Square:
  def __init__(self, side):
    self.side = side
    
  def get_area(self):
    return self.side ** 2

def print_area(shape):
  print(f"The area of the shape is {shape.get_area()}")

circle = Circle(5)
square = Square(4)

print_area(circle)  # Output: The area of the shape is 78.5
print_area(square)  # Output: The area of the shape is 16

In this example, we have defined two classes Circle and Square, both of which have a get_area method. We have also defined a function print_area that takes an argument shape, which can be an object of either class Circle or Square.

When we call print_area(circle) and print_area(square), the get_area method of the corresponding object is called and the area of the shape is printed.

This is an example of polymorphism because the print_area function can be used with objects of different classes (Circle and Square) that have the same method (get_area).

Inheritance vs. Composition

Inheritance and composition are two ways to create new classes by combining existing classes.

Inheritance is a mechanism by which a new class is derived from an existing class, inheriting all the attributes and methods of the parent class. Inheritance allows us to create a hierarchy of classes that share common attributes and methods, while also allowing for specialization of those attributes and methods in the child classes.

Composition is a mechanism by which a class is composed of other objects, typically as instance variables. Composition allows us to create complex objects by combining simpler objects, and it can often be a more flexible alternative to inheritance.

Let’s look at an example of each:

Inheritance example:

class Animal:
  def __init__(self, name):
    self.name = name

  def speak(self):
    pass

class Dog(Animal):
  def speak(self):
    return "Woof!"

class Cat(Animal):
  def speak(self):
    return "Meow!"

dog = Dog("Fido")
cat = Cat("Whiskers")

print(dog.name)  # Output: Fido
print(dog.speak())  # Output: Woof!
print(cat.name)  # Output: Whiskers
print(cat.speak())  # Output: Meow!

In this example, we have a superclass Animal with an __init__ method that initializes the name attribute, and a speak method that doesn’t do anything. We also have two subclasses Dog and Cat that inherit from the Animal class.

The Dog class overrides the speak method with a Woof! string, while the Cat class overrides the speak method with a Meow! string.

Composition example:

class Engine:
  def start(self):
    print("Engine started.")

  def stop(self):
    print("Engine stopped.")

class Car:
  def __init__(self):
    self.engine = Engine()

  def start(self):
    self.engine.start()

  def stop(self):
    self.engine.stop()

car = Car()
car.start()  # Output: Engine started.
car.stop()  # Output: Engine stopped.

In this example, we have a Car class that has an instance variable engine that is an object of the Engine class. The Car class has its own start and stop methods that delegate to the corresponding methods of the Engine object.

This is an example of composition because the Car class is composed of an Engine object, and it delegates the start and stop methods to the Engine object.

Private

In Python, there is no true “private” variables or methods in a class. However, developers use a naming convention to indicate that a variable or method should not be accessed outside of the class.

The convention is to prefix the variable or method name with a double underscore __ (also known as “dunder”). For example, a private variable in a class called Person could be named __name.

By convention, variables and methods with a double underscore prefix are considered “private” and should not be accessed directly from outside the class. However, it is still possible to access these variables and methods by using name mangling, which involves prefixing the variable or method name with _classname (where classname is the name of the class).

Here’s an example:

class Person:
    def __init__(self, name):
        self.__name = name

    def get_name(self):
        return self.__name

person = Person("Sachin")
print(person.get_name())  # Output:Sachin
print(person.__name)  # Raises an AttributeError
print(person._Person__name)  # Output:Sachin

In this example, we have a private variable __name in the Person class, and a public method get_name that returns the value of __name.

When we try to access __name directly using person.__name, we get an AttributeError, indicating that the variable is not accessible from outside the class.

However, we can still access the private variable using name mangling with _Person__name. This is not recommended, as it goes against the principle of encapsulation and can lead to unexpected behavior. It’s best to only access private variables and methods through the public interface of the class.

Metaclass

In Python, a metaclass is a class that defines the behavior of other classes. When you define a class in Python, it is actually an instance of its metaclass. Metaclasses allow you to customize the behavior of classes in ways that are not possible using ordinary Python syntax.

The base metaclass in Python is the type metaclass

To define a metaclass in Python, you can create a new class that inherits from the built-in type class. The type class is itself a metaclass, so by subclassing it, you can create your own metaclass. Here’s an example:

class MyMeta(type):
  def __new__(cls, name, bases, attrs):
    print("Creating class", name)
    return super().__new__(cls, name, bases, attrs)

class MyClass(metaclass=MyMeta):
  pass

class MySubclass(MyClass):
  pass

In this example, we define a new metaclass called MyMeta that inherits from type. The __new__ method is called when a new class is created using this metaclass. It takes four arguments: cls, which is the metaclass itself, name, which is the name of the new class, bases, which is a tuple of the base classes of the new class, and attrs, which is a dictionary of the attributes of the new class.

In this example, the __new__ method simply prints a message when a new class is created. We then define two classes, MyClass and MySubclass, and specify MyMeta as the metaclass for both of them using the metaclass argument.

When we run this code, we see that the __new__ method of MyMeta is called twice, once for MyClass and once for MySubclass.

Metaclasses can be used to implement a wide range of custom behaviors for classes. For example, you could use a metaclass to automatically generate classes based on a set of configuration options, or to enforce certain constraints on the attributes of classes. However, metaclasses can be difficult to understand and use correctly, so they should be used with caution.

Metaclasses are a powerful and advanced feature of Python that allow you to create classes dynamically at runtime. They are especially useful for implementing frameworks, libraries, and other systems that need to generate classes programmatically.

In addition to the __new__ method shown in the previous example, metaclasses can also implement other special methods that control the behavior of the classes they create. For example:

  • __init__(cls, name, bases, attrs): This method is called after the __new__ method and is used to initialize the class object. It takes the same arguments as __new__.
  • __call__(cls, *args, **kwargs): This method is called when an instance of the class is created. It takes the class object as its first argument, followed by any additional arguments passed to the class constructor.
  • __getattr__(cls, name): This method is called when an attribute of the class is accessed that does not exist. It allows you to define custom behavior for attribute access in the class.

Here’s an example of a metaclass that uses the __call__ method to customize the behavior of instances of the class:

class Singleton(type):
  _instances = {}

  def __call__(cls, *args, **kwargs):
    if cls not in cls._instances:
      cls._instances[cls] = super().__call__(*args, **kwargs)
    return cls._instances[cls]

class MyClass(metaclass=Singleton):
  pass

a = MyClass()
b = MyClass()

assert a is b

In this example, we define a metaclass called Singleton that ensures that only one instance of the class is ever created. The _instances attribute is a dictionary that stores the instances of the class that have been created. The __call__ method checks if an instance of the class has already been created and returns it if it has. Otherwise, it creates a new instance using the super().__call__ method.

We then define a class MyClass that uses the Singleton metaclass by specifying it as the metaclass argument. When we create two instances of MyClass, a and b, we see that they are both the same object, because the Singleton metaclass ensures that only one instance of the class is ever created.

Abstract Interface

Metaclasses can also be used to define abstract interfaces in Python. An abstract interface is a set of methods that a class must implement in order to be considered a valid implementation of the interface. Abstract interfaces are commonly used in object-oriented programming to define a common set of functionality that can be implemented by multiple classes.

To define an abstract interface using metaclasses, you can create a new metaclass that defines the set of methods that must be implemented by classes that use the metaclass. Here’s an example:

class AbstractInterface(type):
  def __new__(cls, name, bases, attrs):
    if not any('required_method' in base.__dict__ for base in bases):
      raise TypeError(f'{name} does not implement required methods')
    return super().__new__(cls, name, bases, attrs)

class MyInterface(metaclass=AbstractInterface):
  def required_method(self):
    pass

class ValidImplementation(MyInterface):
  def required_method(self):
    print('ValidImplementation implements required_method')

class InvalidImplementation(MyInterface):
  pass

In this example, we define a metaclass called AbstractInterface that checks whether any of the base classes of a new class that uses the metaclass define a required_method method. If none of the base classes define this method, the metaclass raises a TypeError.

We then define a new interface called MyInterface that defines a single required_method method. We also define two classes, ValidImplementation and InvalidImplementation, that inherit from MyInterface. ValidImplementation implements the required_method method correctly, while InvalidImplementation does not.

When we try to create an instance of InvalidImplementation, we see that a TypeError is raised because the class does not implement the required_method method defined by the MyInterface interface.

Abstract interfaces can be a useful way to enforce a common set of functionality across multiple classes in an object-oriented program. Using a metaclass to define the interface allows you to define the set of required methods in a single location and ensure that any class that uses the interface implements these methods correctly.

Example

In Python, you can create an interface using an abstract base class (ABC) with the abc module. An abstract base class is a class that cannot be instantiated directly, but can be subclassed to create concrete classes.

Here’s an example interface using a Python class:

import abc

class Shape(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def area(self):
        pass

    @abc.abstractmethod
    def perimeter(self):
        pass

class Square(Shape):
  def __init__(self, side):
    self.side = side

  def area(self):
    return self.side ** 2

  def perimeter(self):
    return 4 * self.side

class Circle(Shape):
  def __init__(self, radius):
    self.radius = radius

  def area(self):
    return 3.14 * self.radius ** 2

  def perimeter(self):
    return 2 * 3.14 * self.radius

# This will raise a TypeError, because Shape is an abstract class
s = Shape()

# This will work, because Square is a concrete class that implements the area and perimeter methods
sq = Square(5)
print(sq.area())  # Output: 25
print(sq.perimeter())  # Output: 20

# This will work, because Circle is a concrete class that implements the area and perimeter methods
c = Circle(3)
print(c.area())  # Output: 28.26
print(c.perimeter())  # Output: 18.84

In this example, we define an abstract class called Shape that has two abstract methods, area and perimeter. The @abc.abstractmethod decorator is used to mark these methods as abstract, indicating that they must be implemented in any concrete subclass of Shape.

We then define two concrete classes, Square and Circle, that subclass Shape and implement the area and perimeter methods. Because Square and Circle implement all the abstract methods of Shape, they are considered concrete classes that can be instantiated.

When we try to instantiate the abstract Shape class directly, a TypeError is raised, because we cannot create instances of an abstract class. However, we can create instances of Square and Circle, and call their area and perimeter methods to calculate the area and perimeter of the shapes.