Encapsulation is one of the fundamental principles of Object-Oriented Programming (OOP). It is the mechanism of bundling the data (attributes) and methods (functions) that operate on the data into a single unit, typically a class. Encapsulation also restricts direct access to some of an object's components, which is called data hiding.

Why Encapsulation?

1. Data Protection: Prevent accidental modification or misuse of an object’s data.
2. Controlled Access: Expose only the necessary parts of an object to the outside world via getters, setters, or other methods.
3. Improved Maintainability: Makes debugging and updating the code easier by isolating functionality.
4. Abstraction: Helps abstract complex systems into simpler interfaces, showing only what is necessary.

How Encapsulation Works in Python

In Python, encapsulation is implemented using:
1. Public Attributes: Accessible from anywhere.
2. Protected Attributes: Indicated by a single underscore _attribute, intended for internal use but still accessible.
3. Private Attributes: Indicated by a double underscore __attribute, name-mangled to make access difficult from outside the class.

Real-Life Analogy

Think of a bank account:
• Your account balance is private (you can’t access it directly).
• You can use methods like deposit() and withdraw() to safely interact with your balance.
• This ensures your account isn't accidentally or maliciously modified.

Examples of Encapsulation in Python

Example 1. Public Attributes

Public attributes are accessible from anywhere, but they provide no restriction.

class Car:
     def __init__(self, brand):
        self.brand = brand  # Public attribute

# Usage
my_car = Car("Toyota")
print(my_car.brand)  # Output: Toyota

my_car.brand = "Honda"  # Attribute can be modified directly
print(my_car.brand)  # Output: Honda

Example 2. Protected Attributes

Protected attributes are prefixed with a single underscore _. This indicates they are for internal use, though not strictly private.

    class Car:
    def __init__(self, brand, engine_status):
        self._brand = brand           # Protected attribute
        self._engine_status = engine_status  # Protected attribute

    def start_engine(self):
        if not self._engine_status:
            self._engine_status = True
            return "Engine started"
        return "Engine is already running"

# Usage
my_car = Car("Toyota", False)

print(my_car.start_engine())  # Output: Engine started
print(my_car._brand)          # Output: Toyota (Still accessible, but discouraged)

Example 3. Private Attributes

Private attributes are prefixed with double underscores __. Python performs name mangling to make them harder to access from outside the class.

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance  # Private attribute

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

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            return f"Withdrew {amount}, Remaining Balance: {self.__balance}"
        return "Invalid withdrawal amount"

# Usage
account = BankAccount(1000)
print(account.deposit(500))     # Output: Deposited 500, New Balance: 1500
print(account.withdraw(200))    # Output: Withdrew 200, Remaining Balance: 1300

# Accessing private attribute (raises error)
# print(account.__balance)      # AttributeError

# Name-mangling allows access, but it is discouraged
print(account._BankAccount__balance)  # Output: 1300
Getters and Setters for Controlled Access
To control access to private or protected attributes, use getter and setter methods.
class Person:
    def __init__(self, name, age):
        self.__name = name  # Private attribute
        self.__age = age    # Private attribute

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

    # Setter for name
    def set_name(self, name):
        if name:
            self.__name = name
        else:
            raise ValueError("Name cannot be empty")

    # Getter for age
    def get_age(self):
        return self.__age

    # Setter for age
    def set_age(self, age):
        if age > 0:
            self.__age = age
        else:
            raise ValueError("Age must be positive")

# Usage
person = Person("Alice", 30)
print(person.get_name())  # Output: Alice
print(person.get_age())   # Output: 30

person.set_name("Bob")
person.set_age(35)
print(person.get_name())  # Output: Bob
print(person.get_age())   # Output: 35

Example 4: Library System

Here's an example of a library system using encapsulation to manage books.

class Library:
    def __init__(self):
        self.__books = ["Python Basics", "Data Science", "AI and ML"]

    # Getter method
    def get_books(self):
        return self.__books

    # Setter method
    def add_book(self, book):
        if book:
            self.__books.append(book)
            return f"{book} added to library"
        return "Invalid book name"

# Usage
library = Library()
print(library.get_books())  # Output: ['Python Basics', 'Data Science', 'AI and ML']

print(library.add_book("Deep Learning"))  # Output: Deep Learning added to library
print(library.get_books())  # Output: ['Python Basics', 'Data Science', 'AI and ML', 'Deep Learning']

Advantages of Encapsulation

1. Improved Security:
Protects sensitive data by restricting access.

2. Reduces Complexity:
Hides internal implementation details from the outside world.

3. Increases Flexibility:
Enables controlled access to data via getter and setter methods.

4. Better Code Organization:
Groups related data and methods together in a single class.

Best Practices

1. Use Private Attributes for Sensitive Data:
Protect sensitive data by making them private (__attribute).

2. Expose Only Necessary Functionality:
Use getter and setter methods to control what is accessible outside the class.

3. Avoid Overusing Private Attributes:
Don't make everything private; only sensitive data or methods need protection.

4. Document Access Restrictions:
Indicate protected/private attributes clearly in the class docstring or comments.

Summary

• Encapsulation: Combines data and methods into a single unit (class) and restricts direct access to some components.

• Levels of Access: Public (attribute), Protected (_attribute), Private (__attribute).

• Benefits: Data protection, controlled access, abstraction, and maintainability.

• Techniques: Use private attributes with getters and setters for controlled access.