4.3. Encapsulation and Abstraction#

4.3.1. Encapsulation#

Encapsulation is a fundamental principle in object-oriented programming (OOP) that emphasizes bundling data (attributes) and methods (functions) that operate on that data within a single unit called a class. This practice restricts direct external access to the internal components of the class, promoting data integrity and security. Encapsulation is achieved by using access modifiers such as private and protected to control the visibility of attributes and methods [Lin et al., 2022, Nayak and Gupta, 2022].

Definition - Encapsulation

Encapsulation involves encapsulating data and methods within a class, controlling access to data and ensuring data integrity.

# Define a class called Employee
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.__salary = salary  # Private attribute
        
    def get_salary(self):
        # Getter method to access the private salary attribute
        return self.__salary
    
    def set_salary(self, new_salary):
        # Setter method to modify the private salary attribute
        if new_salary > 0:
            self.__salary = new_salary

# Creating an instance of Employee
employee = Employee("Alice", 50000)

# Accessing and modifying salary through methods
print(employee.get_salary())  # Output: 50000

employee.set_salary(55000)
print(employee.get_salary())  # Output: 55000
50000
55000

In the given code snippet, we have a class named Employee, and here’s how it demonstrates encapsulation:

  1. Private Attribute (__salary):

    • The class Employee has an attribute called __salary, which is designated as private by using double underscores (__). This means that it’s not directly accessible from outside the class.

  2. Getter Method (get_salary()):

    • To access the private __salary attribute, the class provides a public method called get_salary(). This method allows external code to retrieve the salary value indirectly.

  3. Setter Method (set_salary(new_salary)):

    • To modify the salary, the class provides a public method called set_salary(new_salary). This method allows external code to change the salary value but enforces a validation check to ensure that the new salary is greater than zero before making the update.

  4. Usage of Encapsulation:

    • When an instance of the Employee class is created, like in this case with “Alice” and an initial salary of 50000, the actual salary value is encapsulated within the object.

    • To access or modify this salary, external code must use the provided getter and setter methods (get_salary() and set_salary(new_salary)). This encapsulation prevents direct access to the __salary attribute from outside the class, ensuring data integrity and control over how it can be manipulated.

Note

  1. In simple words, encapsulation in programming means bundling data (attributes or variables) and the methods (functions or procedures) that operate on that data into a single unit called a class. It’s like putting data and the code that works with that data inside a container to keep them together and hide the inner details from the outside world. This helps in organizing code, maintaining data integrity, and controlling access to the data by providing interfaces (methods) for interacting with it.

  2. The most common way to encapsulate attributes in Python is by using one or two underscores as a naming convention. This convention is widely followed in the Python community to indicate the intended level of visibility and access control for class attributes:

    1. Single Underscore _: A single underscore prefix (e.g., _salary) is used to indicate that an attribute is intended for internal use within the class or module. It’s a signal to other developers that this attribute should be treated as non-public but is not enforced by the language itself.

    2. Double Underscore __: A double underscore prefix (e.g., __salary) invokes name mangling, which alters the attribute’s name to make it less accessible and harder to accidentally override in subclasses. While it doesn’t make the attribute completely private, it provides a higher level of encapsulation.

4.3.1.1. Common ways to define private attributes for encapsulation (Optional Content)#

  1. Single Underscore Prefix (_): Attributes with a single underscore prefix (e.g., _variable_name) are considered “protected” rather than truly private. This naming convention signals to other developers that these attributes should not be accessed directly from outside the class, but it does not prevent access.

Example:

class MyClass:
    def __init__(self):
        self._private_var = 42  # Initializes a protected attribute _private_var with a value of 42

# Attempting to access MyClass._private_var directly would result in:
# AttributeError: type object 'MyClass' has no attribute '_private_var'
  1. Double Underscore Prefix (Name Mangling): Attributes with a double underscore prefix (e.g., __variable_name) undergo name mangling, which changes the attribute’s name to make it harder to accidentally override in subclasses. While it doesn’t make the attribute completely private, it provides a higher level of encapsulation.

Example:

class MyClass:
    def __init__(self):
        self.__private_var = 42  # Initializes a name-mangled private attribute __private_var with a value of 42

# Attempting to access MyClass.__private_var directly would result in:
# AttributeError: type object 'MyClass' has no attribute '__private_var'
  1. Use of @property and @setter Method: You can use property methods to encapsulate attribute access and modification. This allows you to control how attributes are accessed and set while maintaining a consistent interface.

Example:

class MyClass:
    def __init__(self):
        self._value = 0  # Initializes the protected attribute _value with a default value of 0

    @property
    def value(self):
        return self._value  # This is a getter method that allows you to access _value

    @value.setter
    def value(self, new_value):
        if new_value > 0:
            self._value = new_value  # This is a setter method that allows you to modify _value if the new value is greater than 0

# Creating an instance of MyClass
my_instance = MyClass()

# Getting the value attribute
print(my_instance.value)  # Output: 0

# Setting the value attribute to 42
my_instance.value = 42
print(my_instance.value)  # Output: 42

# Attempting to set a negative value (value remains unchanged)
my_instance.value = -5
print(my_instance.value)  # Output: 42 (value remains unchanged)
0
42
42

The private (protected) attribute is indeed _value, but the property name is value. The property value provides a controlled interface for accessing and setting the _value attribute. So, when you access it using my_instance.value, you are actually accessing the getter method, which returns the value of _value. When you set it using my_instance.value = 42, you are invoking the setter method, which modifies the _value attribute.

The naming of the property and the private attribute does not have to match. In this case, value is used as a more user-friendly and descriptive name for the property, while _value remains the name of the private attribute. This allows you to expose a public interface (value) while encapsulating the implementation details of _value.

4.3.2. Abstraction#

Abstraction involves simplifying complex reality by modeling classes based on real-world entities and focusing on their essential attributes and behaviors, while hiding unnecessary implementation details. It provides a clear and simplified interface for interacting with classes, allowing users to utilize functionalities without understanding the intricacies of the underlying implementation [Lin et al., 2022, Wilson, 2022].

Definition - Abstraction

Abstraction involves presenting essential attributes and methods while hiding implementation details, making interaction with the class simpler.

# Define an abstract class called Shape
class Shape:
    def __init__(self, name):
        # Initialize the shape's name
        self.name = name
    
    def area(self):
        # This method is meant to be overridden by subclasses
        pass

# Define a Circle class that inherits from Shape
class Circle(Shape):
    def __init__(self, name, radius):
        # Initialize the Circle with a name and radius
        super().__init__(name)
        self.radius = radius
    
    def area(self):
        # Calculate and return the area of the Circle
        return 3.14 * self.radius * self.radius

# Define a Rectangle class that inherits from Shape
class Rectangle(Shape):
    def __init__(self, name, length, width):
        # Initialize the Rectangle with a name, length, and width
        super().__init__(name)
        self.length = length
        self.width = width
    
    def area(self):
        # Calculate and return the area of the Rectangle
        return self.length * self.width

# Creating instances of shapes
circle = Circle("Circle", 5)
rectangle = Rectangle("Rectangle", 4, 6)

# Using the abstraction of calculating area
print(f"{circle.name} area: {circle.area()}")
print(f"{rectangle.name} area: {rectangle.area()}")
Circle area: 78.5
Rectangle area: 24

In the given code snippet, we have a base class Shape and two derived classes, Circle and Rectangle. Here’s how this code demonstrates abstraction:

  1. Abstract Base Class (Shape):

    • The Shape class is defined as an abstract base class, which means it cannot be instantiated directly. It serves as a blueprint for other shapes but doesn’t provide a concrete implementation for the area() method. Instead, it declares the area() method as an abstract method using pass. This enforces that any subclass derived from Shape must provide its own implementation of the area() method.

  2. Concrete Derived Classes (Circle and Rectangle):

    • The Circle and Rectangle classes are derived from the Shape class, inheriting its attributes and behaviors (in this case, the name attribute and the area() method).

    • Each derived class (Circle and Rectangle) provides its own specific implementation of the area() method, which calculates the area based on the properties unique to that shape. For instance, the Circle class calculates the area based on its radius, while the Rectangle class calculates it based on its length and width.

  3. Usage of Abstraction:

    • When instances of Circle and Rectangle are created, the code doesn’t need to know the specific details of how the area is calculated for each shape. It only needs to interact with the abstract concept of a “shape” and call the area() method. This is an example of abstraction, as it allows you to work with high-level concepts (shapes and their areas) without concerning yourself with the intricate implementation details.

  4. Polymorphism:

    • Another aspect of abstraction demonstrated here is polymorphism. The code uses polymorphism to call the area() method on different objects (circle and rectangle) without knowing their concrete types. The appropriate area() method is dynamically invoked based on the actual object type, which is a powerful feature of abstraction.

Note

In simple words, abstraction in programming means simplifying complex systems by focusing on the essential features and ignoring unnecessary details. It’s like looking at a car and knowing how to drive it without needing to understand all the intricate details of how the engine, transmission, and other components work. Abstraction allows you to work with a high-level representation of something, hiding the inner workings, and providing a clear and simplified interface for users to interact with. It makes complex systems more manageable and understandable.

When a subclass, such as Triangle, does not override the abstract area method and the parent class Shape defines it with the pass statement, the implementation of the area method from the parent class is inherited and used.

Example:

# Define an abstract class called Shape
class Shape:
    def __init__(self, name):
        # Initialize the shape's name
        self.name = name
    
    def area(self):
        # This method is meant to be overridden by subclasses
        pass

# Define a Triangle class that inherits from Shape without overriding area
class Triangle(Shape):
    def __init__(self, name, base, height):
        super().__init__(name)
        self.base = base
        self.height = height

# Creating an instance of Triangle
triangle = Triangle("Triangle", 3, 4)

# Using the inherited abstraction of calculating area from the parent class
area = triangle.area()  # This will will use the parent class's "pass" implementation

In this case, since the Triangle class does not provide its own area method, it inherits the area method with the pass statement from the parent class Shape. Therefore, calling triangle.area() will not raise an error, and it will use the default “pass” implementation from the parent class.