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:
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.
Getter Method (
get_salary()
):To access the private
__salary
attribute, the class provides a public method calledget_salary()
. This method allows external code to retrieve the salary value indirectly.
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.
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()
andset_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
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.
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:
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.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)#
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'
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'
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:
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 thearea()
method. Instead, it declares thearea()
method as an abstract method usingpass
. This enforces that any subclass derived fromShape
must provide its own implementation of thearea()
method.
Concrete Derived Classes (
Circle
andRectangle
):The
Circle
andRectangle
classes are derived from theShape
class, inheriting its attributes and behaviors (in this case, thename
attribute and thearea()
method).Each derived class (
Circle
andRectangle
) provides its own specific implementation of thearea()
method, which calculates the area based on the properties unique to that shape. For instance, theCircle
class calculates the area based on its radius, while theRectangle
class calculates it based on its length and width.
Usage of Abstraction:
When instances of
Circle
andRectangle
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 thearea()
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.
Polymorphism:
Another aspect of abstraction demonstrated here is polymorphism. The code uses polymorphism to call the
area()
method on different objects (circle
andrectangle
) without knowing their concrete types. The appropriatearea()
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.