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
- Encapsulation bundles data and methods together while hiding internal state
- Data hiding prevents direct access to object’s internal data
- Access modifiers in Python are conventions, not enforced by the language
- Private attributes use double underscore (
__) and name mangling - Protected attributes use single underscore (
_) as a convention - Properties (
@property) provide controlled access to attributes - Getters and setters allow validation and control over data access
- Name mangling makes private attributes harder to access but not impossible
- Encapsulation promotes data integrity and reduces coupling
- 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.
__xinBasebecomes_Base__x, so a subclass defining its own__xdoesn’t overwrite it. - “Getters and setters in Python?” — start with a plain public attribute. Add
@propertyonly when you need validation, computation or a deprecation shim. Writing Java-styleget_x/set_xpairs up front is unidiomatic, and@propertymeans you can add behaviour later without changing the call site. - “When does encapsulation genuinely matter?” — when an invariant spans several attributes. If setting
startandendindependently can produce an invalid range, expose one method that sets both and validates.