Python Encapsulation

Encapsulation is an OOP principle that restricts direct access to certain attributes and methods of a class to protect data from unintended modification. Encapsulation allows us to bundle data (attributes) and methods that operate on the data into a single unit (class) while controlling how external code interacts with them.

Python achieves encapsulation through attribute visibility levels: public, protected, and private.

1. Public Attributes and Methods

Attributes and methods in Python are public by default, which means they can be accessed and modified freely from outside the class. This approach is common for attributes that don't require special access control.

Example of Public Attributes:
class Car:
    def __init__(self, brand, speed):
        self.brand = brand  # Public attribute
        self.speed = speed  # Public attribute

    def display_info(self):  # Public method
        return f"Car brand: {self.brand}, Speed: {self.speed}"

car = Car("Toyota", 120)
print(car.display_info())  # Accessing public method
print(car.brand)           # Accessing public attribute

Output:
Car brand: Toyota, Speed: 120
Toyota

Explanation: Here, `brand` and `speed` are public attributes, and `display_info` is a public method. They are accessible directly from outside the class.

2. Protected Attributes and Methods

Protected attributes and methods use a single underscore (`_`) prefix, indicating they should not be accessed directly outside the class hierarchy. Although they are technically accessible, by convention, they are intended for internal use within the class and its subclasses.

Example of Protected Attributes:
class Vehicle:
    def __init__(self, name, speed):
        self._name = name      # Protected attribute
        self._speed = speed    # Protected attribute

    def _display_info(self):   # Protected method
        return f"Vehicle name: {self._name}, Speed: {self._speed}"

class Bike(Vehicle):
    def bike_info(self):
        return self._display_info()  # Accessing protected method

vehicle = Vehicle("Truck", 80)
print(vehicle._name)             # Accessing protected attribute (not recommended)
print(vehicle._display_info())    # Accessing protected method (not recommended)

bike = Bike("Mountain Bike", 45)
print(bike.bike_info())           # Accessing protected method within subclass

Output:
Truck
Vehicle name: Truck, Speed: 80
Vehicle name: Mountain Bike, Speed: 45

Explanation: The `_name` and `_speed` attributes and the `_display_info` method are protected. Although we can access them directly, convention suggests using them only within the class or its subclasses.

3. Private Attributes and Methods

Private attributes and methods use a double underscore (`__`) prefix, making them less accessible from outside the class. This is achieved through name mangling, which prevents direct access by altering the name internally.

Example of Private Attributes:
class Account:
    def __init__(self, owner, balance):
        self.__owner = owner       # Private attribute
        self.__balance = balance   # Private attribute

    def __display_balance(self):   # Private method
        return f"Account owner: {self.__owner}, Balance: ${self.__balance}"

    def get_balance(self):
        return self.__display_balance()  # Public method to access private method

account = Account("Alice", 1000)
print(account.get_balance())        # Accessing private method through a public method
print(account._Account__owner)      # Accessing private attribute with name mangling (not recommended)

Output:
Account owner: Alice, Balance: $1000
Alice

Explanation: The `__owner` and `__balance` attributes and the `__display_balance` method are private. They can only be accessed indirectly through the `get_balance` method or directly via name mangling (e.g., `_Account__owner`), though the latter is discouraged.

4. Getters and Setters for Controlled Access

Python allows controlled access to private attributes through getters and setters, providing indirect access while protecting the integrity of the data.

Example of Getters and Setters:
class Employee:
    def __init__(self, name, salary):
        self.__name = name          # Private attribute
        self.__salary = salary      # Private attribute

    # Getter method for name
    def get_name(self):
        return self.__name

    # Setter method for name
    def set_name(self, name):
        self.__name = name

    # Getter and setter for salary with validation
    def get_salary(self):
        return self.__salary

    def set_salary(self, salary):
        if salary < 0:
            raise ValueError("Salary cannot be negative")
        self.__salary = salary

employee = Employee("John", 50000)
print(employee.get_name())  # Accessing name through getter
employee.set_salary(55000)  # Setting new salary through setter
print(employee.get_salary())

Output:
John
55000

Explanation: The `get_name` and `set_name` methods allow controlled access to the `__name` attribute. Similarly, the `get_salary` and `set_salary` methods provide controlled access and validation for the `__salary` attribute.

5. Using Property Decorators for Encapsulation

Property decorators (`@property`, `@<attribute>.setter`) allow defining getter and setter methods in a concise way. This enables direct access syntax while keeping the encapsulation benefits of getters and setters.

Example Using Property Decorators:
class Product:
    def __init__(self, name, price):
        self.__name = name         # Private attribute
        self.__price = price       # Private attribute

    @property
    def price(self):               # Getter method
        return self.__price

    @price.setter
    def price(self, value):        # Setter method with validation
        if value < 0:
            raise ValueError("Price cannot be negative")
        self.__price = value

product = Product("Laptop", 1000)
print("Price:", product.price)    # Accessing price through property
product.price = 1200              # Setting price through property setter
print("Updated Price:", product.price)

Output:
Price: 1000
Updated Price: 1200

Explanation: Using `@property` for `price` allows us to read `price` like a regular attribute, while `@price.setter` enables setting it with validation. This provides clean syntax while protecting data integrity.

6. Encapsulation in Real-World Scenarios

Encapsulation is particularly useful in applications where data consistency and security are important, such as banking or inventory management systems.

Example of Encapsulation in a Banking System:
class BankAccount:
    def __init__(self, account_number, balance):
        self.__account_number = account_number  # Private attribute
        self.__balance = balance                # Private attribute

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            return f"${amount} deposited. New balance: ${self.__balance}"
        return "Invalid deposit amount"

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            return f"${amount} withdrawn. New balance: ${self.__balance}"
        return "Insufficient funds or invalid amount"

    def get_balance(self):
        return self.__balance  # Public method to access private attribute

# Example usage
account = BankAccount("12345", 1000)
print(account.deposit(500))
print(account.withdraw(200))
print("Current Balance:", account.get_balance())

Output:
$500 deposited. New balance: $1500
$200 withdrawn. New balance: $1300
Current Balance: 1300

Explanation: Here, encapsulation prevents direct access to sensitive attributes like `__account_number` and `__balance`. Only the `deposit`, `withdraw`, and `get_balance` methods allow controlled access, ensuring data integrity.

7. Summary

Encapsulation in Python allows us to:

- Protect Data Integrity: By restricting direct access to attributes, we can maintain the consistency and integrity of data.

- Simplify Interface: Expose only necessary methods for external use, simplifying how other code interacts with the class.

- Control Modification: Using setters, we can enforce rules (e.g., validation) when modifying data.

Previous: Python Inheritance | Next: Python Polymorphism

<
>