backend / python oop / 05_encapsulation_in_python.md

Encapsulation in Python

4 interview angles 8 min read source

Encapsulation in Python

Encapsulation is one of the four fundamental principles of Object-Oriented Programming (OOP). It refers to the bundling of data (attributes) and methods that operate on that data within a single unit (class), while hiding the internal state and requiring all interactions to be performed through an object’s methods.

The goal is to prevent direct access to some of an object’s components and to prevent unauthorized access and modification of data.


What is Encapsulation?

Encapsulation combines:

  • Data hiding: Protecting data from direct access
  • Data bundling: Grouping related data and methods together
  • Access control: Controlling how data can be accessed and modified
class BankAccount:
    def __init__(self, account_holder, initial_balance):
        self.__account_holder = account_holder  # Private attribute
        self.__balance = initial_balance        # Private attribute
        self.__account_number = self.__generate_account_number()

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

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

    def get_balance(self):
        return self.__balance

    def get_account_info(self):
        return f"Account: {self.__account_number}, Holder: {self.__account_holder}"

    def __generate_account_number(self):
        import random
        return f"ACC{random.randint(10000, 99999)}"

# Usage
account = BankAccount("Alice", 1000)
print(account.deposit(500))      # Deposited $500. New balance: $1500
print(account.withdraw(200))     # Withdrew $200. New balance: $1300
print(account.get_balance())     # 1300
print(account.get_account_info()) # Account: ACC12345, Holder: Alice

# Direct access to private attributes is restricted
# account.__balance  # AttributeError
# account.__account_holder  # AttributeError

Access Modifiers in Python

Python uses naming conventions to indicate access levels:

Public Attributes

  • No special prefix
  • Accessible from anywhere
class Person:
    def __init__(self, name, age):
        self.name = name    # Public attribute
        self.age = age      # Public attribute

    def introduce(self):
        return f"Hi, I'm {self.name} and I'm {self.age} years old"

person = Person("Alice", 30)
print(person.name)  # Alice (direct access allowed)
print(person.introduce())  # Hi, I'm Alice and I'm 30 years old

Protected Attributes

  • Single underscore prefix _
  • Convention indicating “internal use”
  • Still accessible but indicates “don’t touch”
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self._salary = salary  # Protected attribute

    def get_salary(self):
        return self._salary

    def _calculate_bonus(self):
        return self._salary * 0.1

employee = Employee("Bob", 50000)
print(employee._salary)  # 50000 (accessible but not recommended)
print(employee.get_salary())  # 50000 (proper way)

Private Attributes

  • Double underscore prefix __
  • Name mangling prevents direct access
  • Most restrictive access level
class Student:
    def __init__(self, name, student_id):
        self.name = name
        self.__student_id = student_id  # Private attribute
        self.__grades = []              # Private attribute

    def add_grade(self, grade):
        if 0 <= grade <= 100:
            self.__grades.append(grade)
            return "Grade added successfully"
        return "Invalid grade"

    def get_average(self):
        if self.__grades:
            return sum(self.__grades) / len(self.__grades)
        return 0

    def get_student_id(self):
        return self.__student_id

student = Student("Charlie", "S12345")
student.add_grade(85)
student.add_grade(92)
print(student.get_average())  # 88.5

# Direct access to private attributes fails
# student.__student_id  # AttributeError
# student.__grades  # AttributeError

Getters and Setters

Property Decorators

Python’s recommended way to implement getters and setters:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9

temp = Temperature(25)
print(temp.celsius)      # 25
print(temp.fahrenheit)   # 77.0

temp.celsius = 30
print(temp.fahrenheit)   # 86.0

temp.fahrenheit = 100
print(temp.celsius)      # 37.77777777777778

# temp.celsius = -300  # ValueError: Temperature cannot be below absolute zero

Traditional Getter/Setter Methods

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

    def get_width(self):
        return self.__width

    def set_width(self, width):
        if width > 0:
            self.__width = width
        else:
            raise ValueError("Width must be positive")

    def get_height(self):
        return self.__height

    def set_height(self, height):
        if height > 0:
            self.__height = height
        else:
            raise ValueError("Height must be positive")

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

rect = Rectangle(5, 3)
print(rect.get_area())  # 15

rect.set_width(6)
print(rect.get_area())  # 18

# rect.set_width(-1)  # ValueError: Width must be positive

Encapsulation with Class Methods

class ShoppingCart:
    def __init__(self):
        self.__items = []
        self.__total = 0.0

    def add_item(self, item_name, price, quantity=1):
        if price < 0 or quantity < 1:
            raise ValueError("Invalid price or quantity")

        # Check if item already exists
        for item in self.__items:
            if item['name'] == item_name:
                item['quantity'] += quantity
                self.__recalculate_total()
                return f"Updated quantity of {item_name}"

        # Add new item
        self.__items.append({
            'name': item_name,
            'price': price,
            'quantity': quantity
        })
        self.__recalculate_total()
        return f"Added {quantity} {item_name}(s)"

    def remove_item(self, item_name):
        for i, item in enumerate(self.__items):
            if item['name'] == item_name:
                del self.__items[i]
                self.__recalculate_total()
                return f"Removed {item_name}"
        return f"{item_name} not found in cart"

    def get_total(self):
        return self.__total

    def get_items(self):
        return self.__items.copy()  # Return a copy to prevent external modification

    def clear_cart(self):
        self.__items.clear()
        self.__total = 0.0

    def __recalculate_total(self):
        """Private method to recalculate total"""
        self.__total = sum(item['price'] * item['quantity'] for item in self.__items)

# Usage
cart = ShoppingCart()
print(cart.add_item("Apple", 1.50, 3))    # Added 3 Apple(s)
print(cart.add_item("Banana", 0.75, 2))   # Added 2 Banana(s)
print(cart.add_item("Apple", 1.50, 2))    # Updated quantity of Apple
print(cart.get_total())                   # 8.5
print(cart.remove_item("Banana"))         # Removed Banana
print(cart.get_total())                   # 7.0

Data Validation and Encapsulation

class User:
    def __init__(self, username, email, age):
        self.__username = None
        self.__email = None
        self.__age = None

        # Use setters to ensure validation
        self.username = username
        self.email = email
        self.age = age

    @property
    def username(self):
        return self.__username

    @username.setter
    def username(self, value):
        if not isinstance(value, str) or len(value) < 3:
            raise ValueError("Username must be a string with at least 3 characters")
        self.__username = value

    @property
    def email(self):
        return self.__email

    @email.setter
    def email(self, value):
        import re
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        if not re.match(pattern, value):
            raise ValueError("Invalid email format")
        self.__email = value

    @property
    def age(self):
        return self.__age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0 or value > 150:
            raise ValueError("Age must be an integer between 0 and 150")
        self.__age = value

    def display_info(self):
        return f"Username: {self.__username}, Email: {self.__email}, Age: {self.__age}"

# Valid user creation
user1 = User("alice123", "alice@example.com", 25)
print(user1.display_info())

# Invalid inputs will raise exceptions
# user2 = User("ab", "invalid-email", 200)  # Multiple validation errors

Encapsulation in Real-World Scenarios

Database Connection Management

class DatabaseConnection:
    def __init__(self, host, port, database, username, password):
        self.__host = host
        self.__port = port
        self.__database = database
        self.__username = username
        self.__password = password
        self.__connection = None
        self.__is_connected = False

    def connect(self):
        if not self.__is_connected:
            # Simulate database connection
            self.__connection = f"Connected to {self.__database} on {self.__host}:{self.__port}"
            self.__is_connected = True
            return "Connected successfully"
        return "Already connected"

    def disconnect(self):
        if self.__is_connected:
            self.__connection = None
            self.__is_connected = False
            return "Disconnected successfully"
        return "Not connected"

    def execute_query(self, query):
        if not self.__is_connected:
            raise ConnectionError("Not connected to database")
        return f"Executing: {query}"

    def is_connected(self):
        return self.__is_connected

    def get_connection_info(self):
        return f"Host: {self.__host}, Database: {self.__database}"

# Usage
db = DatabaseConnection("localhost", 5432, "mydb", "user", "pass")
print(db.connect())           # Connected successfully
print(db.execute_query("SELECT * FROM users"))  # Executing: SELECT * FROM users
print(db.disconnect())        # Disconnected successfully

Configuration Management

class AppConfig:
    def __init__(self):
        self.__config = {
            'debug': False,
            'port': 8000,
            'host': 'localhost',
            'database_url': 'sqlite:///app.db',
            'secret_key': 'default-secret-key'
        }
        self.__is_locked = False

    def set_config(self, key, value):
        if self.__is_locked:
            raise RuntimeError("Configuration is locked")

        if key not in self.__config:
            raise KeyError(f"Unknown configuration key: {key}")

        # Validate specific configurations
        if key == 'port' and (not isinstance(value, int) or value < 1 or value > 65535):
            raise ValueError("Port must be an integer between 1 and 65535")

        if key == 'debug' and not isinstance(value, bool):
            raise ValueError("Debug must be a boolean")

        self.__config[key] = value

    def get_config(self, key):
        if key not in self.__config:
            raise KeyError(f"Unknown configuration key: {key}")
        return self.__config[key]

    def lock_config(self):
        """Lock configuration to prevent further changes"""
        self.__is_locked = True

    def unlock_config(self):
        """Unlock configuration for changes"""
        self.__is_locked = False

    def get_all_config(self):
        return self.__config.copy()

# Usage
config = AppConfig()
config.set_config('debug', True)
config.set_config('port', 9000)
print(config.get_config('debug'))  # True
print(config.get_config('port'))   # 9000

config.lock_config()
# config.set_config('port', 8000)  # RuntimeError: Configuration is locked

Name Mangling

Python’s name mangling mechanism:

class Example:
    def __init__(self):
        self.public_var = "public"
        self._protected_var = "protected"
        self.__private_var = "private"

    def public_method(self):
        return "public method"

    def _protected_method(self):
        return "protected method"

    def __private_method(self):
        return "private method"

obj = Example()

# Public access
print(obj.public_var)        # public
print(obj.public_method())   # public method

# Protected access (convention only)
print(obj._protected_var)        # protected
print(obj._protected_method())   # protected method

# Private access (name mangling)
# print(obj.__private_var)      # AttributeError
# print(obj.__private_method()) # AttributeError

# But you can still access with mangled names
print(obj._Example__private_var)      # private
print(obj._Example__private_method()) # private method

Summary Table

Access Level Prefix Accessibility Example
Public None Anywhere self.name
Protected _ Convention only self._salary
Private __ Name mangling self.__balance
Property @property Controlled access @property def name(self):
Getter/Setter Methods Explicit control get_name(), set_name()

Key Interview Points

  1. Encapsulation bundles data and methods together while hiding internal state
  2. Data hiding prevents direct access to object’s internal data
  3. Access modifiers in Python are conventions, not enforced by the language
  4. Private attributes use double underscore (__) and name mangling
  5. Protected attributes use single underscore (_) as a convention
  6. Properties (@property) provide controlled access to attributes
  7. Getters and setters allow validation and control over data access
  8. Name mangling makes private attributes harder to access but not impossible
  9. Encapsulation promotes data integrity and reduces coupling
  10. Python’s approach to encapsulation is more flexible than strict languages

Benefits of Encapsulation

  • Data Protection: Prevents unauthorized access and modification
  • Data Integrity: Ensures data remains in a valid state
  • Code Maintenance: Changes to internal implementation don’t affect external code
  • Modularity: Objects are self-contained units
  • Flexibility: Internal implementation can change without affecting external interfaces
  • Debugging: Easier to track data changes and identify issues

Encapsulation is essential for creating robust, maintainable, and secure object-oriented code!

Interview angle

  • “Does Python have private attributes?” — no, only conventions. A single underscore is a documented “internal, don’t touch”; a double underscore triggers name mangling to _ClassName__attr, which prevents accidental collisions in subclasses rather than providing access control. Anyone can still reach it.
  • “What is name mangling actually for?” — avoiding attribute clashes between a base class and a subclass, not security. __x in Base becomes _Base__x, so a subclass defining its own __x doesn’t overwrite it.
  • “Getters and setters in Python?” — start with a plain public attribute. Add @property only when you need validation, computation or a deprecation shim. Writing Java-style get_x/set_x pairs up front is unidiomatic, and @property means you can add behaviour later without changing the call site.
  • “When does encapsulation genuinely matter?” — when an invariant spans several attributes. If setting start and end independently can produce an invalid range, expose one method that sets both and validates.