Encapsulation and Abstraction

Author

Davide Vitiello, Mirai Solutions GmbH

Published

March 11, 2025

Encapsulation and Abstraction are key concept in OOP that involve bundling data (attributes) and methods (functions) within a class, controlling access to the internal details.

Encapsulation

Python uses naming conventions to implement encapsulation. By convention, a single underscore (_) prefix indicates a “protected” member and a double underscore (__) prefix indicates a private member of a class.

Note

The only exception to this rule is the dunder/magic methods like __init__, __str__, __repr__, which are methods defined by built-in classes starting and ending with double underscores, which can, in fact, be accessed e.g. in subclasses (e.g. __init__ which can be inherited in a subclass).

“Public” or “private” defines the access scope of class members:

  • Private: Strictly internal, no direct access
  • Protected: Intended for internal use, but subclasses can access
  • Public: Accessible from outside the class

Name Mangling and Private Attributes

Isolation is effectively implemented for private members through a process known as name mangling, which enforces on any attribute that needs to be kept private a strict non-direct access policy. With name-mangling, the Python interpreter replaces any attribute of the form __<name> (at least two leading underscores or at most one trailing underscore) with _classname__<name>. By changing the name of the variable, subclasses are prevented from accidentally overriding its value:

class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance  # Private attribute

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"Added {amount} to the balance")
        else:
            print("Deposit amount must be positive")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"Withdrew {amount} from the balance")
        else:
            print("Insufficient balance or invalid amount")

    def get_balance(self):
        return f"The balance is {self.__balance}"


acc = Account("John")
acc.deposit(100)
acc.get_balance()
Added 100 to the balance
'The balance is 100'
acc.withdraw(50)
acc.get_balance()
Withdrew 50 from the balance
'The balance is 50'

The __balance attribute is encapsulated, making it private and not directly accessible from outside the Account class. The class provides public methods (deposit, withdraw, and get_balance) to interact with the __balance attribute. Trying to access a private attribute directly will raise a AttributeError:

acc.__balance
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[3], line 1
----> 1 acc.__balance

AttributeError: 'Account' object has no attribute '__balance'

Protected Attributes and Methods

The concept of protected attributes is implemented through a naming convention rather than a language-enforced access restriction, unlike private attributes. They serve as a signal to developers that certain attributes are intended for internal use within the class hierarchy. Their effectiveness depends on developers following the convention i.e. avoid accessing them directly from outside the class.

class Vehicle:
    def __init__(self, brand, model):
        self._brand = brand  # Protected attribute
        self._model = model  # Protected attribute

    def vehicle_info(self):
        return f"Vehicle: {self._brand} {self._model}"

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

    def car_info(self):
        base_info = self.vehicle_info()  
        return f"{base_info}, Horsepower: {self.horsepower}"


my_car = Car("Tesla", "Model S", 670)

Below is an example of good practice: the parent class protected attributes _brand and _model are accessed via car_info().

my_car.car_info()
'Vehicle: Tesla Model S, Horsepower: 670'

Below is an example of bad practice, where the aforementioned attributes are accessed directly.

print(f"Vehicle: {my_car._brand} {my_car._model}, Horsepower: {my_car.horsepower}") 
Vehicle: Tesla Model S, Horsepower: 670

Abstraction

The goal of abstraction is to define a structure that must be followed by subclasses without specifying the implementation details. In Python can be achieved by using abstract classes and methods from the abc module. Abstract classes are classes that cannot be instantiated and are designed to be referenced by subclasses. They are defined by inheriting the ABC class from the abc module.

Abstract methods are declared in the abstract class by using the @abstractmethod decorator.

The abc Module

In Python, the abc module is used to implement abstract classes.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    @abstractmethod
    def perimeter(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.__width = width
        self.__height = height

    def area(self):
        return self.__width * self.__height

    def perimeter(self):
        return 2 * (self.__width + self.__height)


rectangle = Rectangle(10, 5)
print(f"Rectangle area: {rectangle.area()}")
print(f"Rectangle perimeter: {rectangle.perimeter()}")
Rectangle area: 50
Rectangle perimeter: 30

Shape is an abstract class that defines a contract for its subclasses by specifying the area and perimeter methods as abstract. Rectangle implements these methods, providing the specific logic to calculate the area and perimeter of a rectangle. This exemplifies abstraction by hiding the internal implementation details of calculating areas and perimeters while exposing a consistent interface to the outside world.

Abstraction supports polymorphism by ensuring all shape subclasses will have area() and perimeter() methods:

import math

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

    def area(self):
        return math.pi * self.__radius ** 2

    def perimeter(self):
        return 2 * math.pi * self.__radius  

shapes = [Rectangle(10, 5), Circle(7)]

for shape in shapes:
    print(f"Shape: {shape.__class__.__name__}, Area: {shape.area()}, Perimeter: {shape.perimeter()}")
Shape: Rectangle, Area: 50, Perimeter: 30
Shape: Circle, Area: 153.93804002589985, Perimeter: 43.982297150257104

Attempting to instantiate the Shape class directly will raise a TypeError:

shape = Shape()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[9], line 1
----> 1 shape = Shape()

TypeError: Can't instantiate abstract class Shape without an implementation for abstract methods 'area', 'perimeter'

Failing to implement all abstract methods in a subclass would also raise a TypeError:

class InvalidShape(Shape):
    pass

invalid_shape = InvalidShape()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[10], line 4
      1 class InvalidShape(Shape):
      2     pass
----> 4 invalid_shape = InvalidShape()

TypeError: Can't instantiate abstract class InvalidShape without an implementation for abstract methods 'area', 'perimeter'
Back to top