- Introduction to Encapsulation
- Understanding Access Levels
- Basic Encapsulation Concepts
- Property Decorators
- Advanced Encapsulation Techniques
- Real-World Applications
- Best Practices
- Common Pitfalls
- Exercises and Practice
Encapsulation is one of the four fundamental principles of Object-Oriented Programming (OOP), alongside inheritance, polymorphism, and abstraction. It's the practice of bundling data (attributes) and methods that operate on that data within a single unit (class), while restricting direct access to some of the object's components.
🎯 Key Benefits:
- Data Protection: Prevents unauthorized access and modification
- Code Maintainability: Changes to internal implementation don't affect external code
- Validation: Ensures data integrity through controlled access
- Abstraction: Hides complex implementation details from users
- Security: Protects sensitive information from direct manipulation
Think of encapsulation like a car's dashboard:
- You can see the speedometer, fuel gauge, and controls (public interface)
- You can't directly access the engine internals (private implementation)
- You interact through standardized controls (methods)
- The car protects you from dangerous components while giving you what you need
Python uses naming conventions to indicate different levels of access:
class Car:
def __init__(self):
self.brand = "Toyota" # Public - accessible everywhereclass Car:
def __init__(self):
self._engine_type = "V6" # Protected - intended for internal useclass Car:
def __init__(self):
self.__serial_number = "ABC123" # Private - name mangled| Access Level | Syntax | Visibility | Use Case |
|---|---|---|---|
| Public | attribute |
Everywhere | External interface |
| Protected | _attribute |
Class & subclasses | Internal implementation |
| Private | __attribute |
Current class only | Sensitive data |
Let's start with a basic example of encapsulation:
class BankAccount:
def __init__(self, account_number, initial_balance=0):
self._account_number = account_number # Protected
self.__balance = initial_balance # Private
def deposit(self, amount):
if amount > 0:
self.__balance += amount
print(f"Deposited ${amount}. New balance: ${self.__balance}")
else:
print("Deposit amount must be positive")
def withdraw(self, amount):
if amount > 0 and amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew ${amount}. New balance: ${self.__balance}")
else:
print("Invalid withdrawal amount")
def get_balance(self):
return self.__balanceKey Points:
_account_numberis protected (internal use)__balanceis private (name mangled to_BankAccount__balance)- Methods provide controlled access to private data
class Example:
def __init__(self):
self.public = "Everyone can see this"
self._protected = "Internal use (convention)"
self.__private = "Name mangled"
# Demonstration
obj = Example()
print(obj.public) # ✅ Works
print(obj._protected) # ✅ Works (but shouldn't be used externally)
# print(obj.__private) # ❌ AttributeError
print(obj._Example__private) # ✅ Works (name mangling revealed)class Person:
def __init__(self, name, age):
self._name = None
self._age = None
self.set_name(name) # Use setter for validation
self.set_age(age)
def set_name(self, name):
if isinstance(name, str) and len(name.strip()) > 0:
self._name = name.strip()
else:
raise ValueError("Name must be a non-empty string")
def set_age(self, age):
if isinstance(age, int) and 0 <= age <= 150:
self._age = age
else:
raise ValueError("Age must be between 0 and 150")
def get_name(self):
return self._name
def get_age(self):
return self._ageProperties provide a Pythonic way to implement getters and setters while maintaining the simplicity of attribute access.
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
"""Getter for celsius"""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Setter for celsius with validation"""
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
"""Computed property for Fahrenheit"""
return (self._celsius * 9/5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Set temperature via Fahrenheit"""
self.celsius = (value - 32) * 5/9
# Usage
temp = Temperature(25)
print(f"Celsius: {temp.celsius}") # 25
print(f"Fahrenheit: {temp.fahrenheit}") # 77.0
temp.fahrenheit = 100
print(f"Celsius: {temp.celsius}") # 37.77777777777778class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
"""Read-only computed property"""
import math
return math.pi * self._radius ** 2
@property
def circumference(self):
"""Read-only computed property"""
import math
return 2 * math.pi * self._radius
# Usage
circle = Circle(5)
print(f"Area: {circle.area:.2f}") # Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Circumference: 31.42
# circle.area = 100 # ❌ AttributeError: can't set attributeclass CacheManager:
def __init__(self):
self._cache = {}
self._data = None
@property
def data(self):
if self._data is None:
print("Loading data...")
self._data = "Expensive computation result"
return self._data
@data.setter
def data(self, value):
self._data = value
self._cache.clear() # Clear cache when data changes
@data.deleter
def data(self):
print("Clearing data and cache...")
self._data = None
self._cache.clear()
# Usage
cache = CacheManager()
print(cache.data) # Loading data... Expensive computation result
cache.data = "New data"
del cache.data # Clearing data and cache...Descriptors provide the most powerful way to customize attribute access:
class ValidatedAttribute:
def __init__(self, validator=None, default=None):
self.validator = validator
self.default = default
self.name = None
def __set_name__(self, owner, name):
self.name = f'_{name}'
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name, self.default)
def __set__(self, instance, value):
if self.validator and not self.validator(value):
raise ValueError(f"Invalid value for {self.name}: {value}")
setattr(instance, self.name, value)
# Validators
def positive_number(value):
return isinstance(value, (int, float)) and value > 0
def non_empty_string(value):
return isinstance(value, str) and len(value.strip()) > 0
class Product:
name = ValidatedAttribute(non_empty_string)
price = ValidatedAttribute(positive_number)
def __init__(self, name, price):
self.name = name
self.price = price
# Usage
product = Product("Laptop", 999.99)
print(f"{product.name}: ${product.price}")
# product.price = -100 # ❌ ValueError: Invalid value for _price: -100class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
# Check if value is already computed
attr_name = f'_lazy_{self.name}'
if not hasattr(instance, attr_name):
# Compute and cache the value
value = self.func(instance)
setattr(instance, attr_name, value)
return getattr(instance, attr_name)
class DataProcessor:
def __init__(self, data):
self.data = data
@LazyProperty
def processed_data(self):
print("Processing data... (expensive operation)")
# Simulate expensive computation
import time
time.sleep(1)
return [x * 2 for x in self.data]
@LazyProperty
def statistics(self):
print("Computing statistics...")
processed = self.processed_data # Uses cached value if available
return {
'sum': sum(processed),
'avg': sum(processed) / len(processed),
'max': max(processed),
'min': min(processed)
}
# Usage
processor = DataProcessor([1, 2, 3, 4, 5])
print("First access:")
print(processor.processed_data) # Processing data... [2, 4, 6, 8, 10]
print("Second access:")
print(processor.processed_data) # [2, 4, 6, 8, 10] (cached)class DatabaseConnection:
def __init__(self, host, port, database):
self._host = host
self._port = port
self._database = database
self.__connection = None
self.__is_connected = False
@property
def is_connected(self):
return self.__is_connected
def connect(self):
if not self.__is_connected:
print(f"Connecting to {self._database} at {self._host}:{self._port}")
# Simulate connection
self.__connection = f"connection_to_{self._database}"
self.__is_connected = True
return self.__connection
def disconnect(self):
if self.__is_connected:
print("Disconnecting from database")
self.__connection = None
self.__is_connected = False
def execute_query(self, query):
if not self.__is_connected:
raise RuntimeError("Must connect to database first")
print(f"Executing: {query}")
return f"Result of {query}"
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.disconnect()
# Usage
with DatabaseConnection("localhost", 5432, "myapp") as db:
result = db.execute_query("SELECT * FROM users")
print(result)class ConfigManager:
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self.__config = {}
self.__readonly_keys = set()
self._initialized = True
def set(self, key, value, readonly=False):
if key in self.__readonly_keys:
raise ValueError(f"Configuration key '{key}' is read-only")
self.__config[key] = value
if readonly:
self.__readonly_keys.add(key)
def get(self, key, default=None):
return self.__config.get(key, default)
def get_all(self):
return self.__config.copy()
@property
def readonly_keys(self):
return self.__readonly_keys.copy()
# Usage
config = ConfigManager()
config.set("database_url", "postgresql://localhost/mydb", readonly=True)
config.set("debug", True)
print(config.get("database_url")) # postgresql://localhost/mydb
# config.set("database_url", "new_url") # ❌ ValueError: read-onlyclass Event:
def __init__(self, name):
self._name = name
self._handlers = []
@property
def name(self):
return self._name
def subscribe(self, handler):
if callable(handler):
self._handlers.append(handler)
else:
raise ValueError("Handler must be callable")
def unsubscribe(self, handler):
if handler in self._handlers:
self._handlers.remove(handler)
def emit(self, *args, **kwargs):
for handler in self._handlers:
try:
handler(*args, **kwargs)
except Exception as e:
print(f"Error in event handler: {e}")
class EventManager:
def __init__(self):
self.__events = {}
def create_event(self, event_name):
if event_name not in self.__events:
self.__events[event_name] = Event(event_name)
return self.__events[event_name]
def get_event(self, event_name):
return self.__events.get(event_name)
@property
def event_names(self):
return list(self.__events.keys())
# Usage
event_manager = EventManager()
user_login = event_manager.create_event("user_login")
def log_login(username):
print(f"User {username} logged in")
def send_welcome_email(username):
print(f"Sending welcome email to {username}")
user_login.subscribe(log_login)
user_login.subscribe(send_welcome_email)
user_login.emit("alice") # Triggers both handlersclass BestPracticeExample:
def __init__(self, public_data, internal_data, sensitive_data):
# Public: Part of the API, safe to access
self.public_data = public_data
# Protected: Internal implementation, may change
self._internal_data = internal_data
# Private: Sensitive or implementation-specific
self.__sensitive_data = sensitive_data
# Public method: Part of the API
def get_summary(self):
return f"Data: {self.public_data}"
# Protected method: For subclasses
def _process_internal_data(self):
return self._internal_data.upper()
# Private method: Implementation detail
def __validate_sensitive_data(self):
return len(self.__sensitive_data) > 0class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError("Width must be positive")
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise ValueError("Height must be positive")
self._height = value
@property
def area(self):
"""Computed property - always up to date"""
return self._width * self._height
@property
def perimeter(self):
"""Computed property - always up to date"""
return 2 * (self._width + self._height)class Email:
def __init__(self, address):
self._address = None
self.address = address # Use setter for validation
@property
def address(self):
return self._address
@address.setter
def address(self, value):
if not self._is_valid_email(value):
raise ValueError(f"Invalid email address: {value}")
self._address = value
@staticmethod
def _is_valid_email(email):
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@property
def domain(self):
return self._address.split('@')[1] if self._address else None
@property
def username(self):
return self._address.split('@')[0] if self._address else None❌ Bad:
class OverEncapsulated:
def __init__(self, x, y):
self.__x = x # Unnecessary private
self.__y = y # Unnecessary private
def __get_x(self): # Unnecessary private method
return self.__x✅ Good:
class WellEncapsulated:
def __init__(self, x, y):
self._x = x # Protected is often sufficient
self._y = y
@property
def x(self):
return self._x❌ Bad:
class BadValidation:
def __init__(self, age):
self.age = age # No validation
def set_age(self, age):
if age < 0:
raise ValueError("Age cannot be negative")
self.age = age # Still allows direct access✅ Good:
class GoodValidation:
def __init__(self, age):
self._age = None
self.age = age # Uses property setter
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("Age cannot be negative")
self._age = value❌ Bad:
class InconsistentAccess:
def __init__(self, name, email):
self.name = name # Public
self._email = email # Protected
def get_name(self): # Unnecessary getter for public attribute
return self.name
# No getter for email, but it's protected✅ Good:
class ConsistentAccess:
def __init__(self, name, email):
self._name = name
self._email = email
@property
def name(self):
return self._name
@property
def email(self):
return self._email-
Basic Encapsulation
- Create a
Studentclass with private attributes for name, age, and grades - Add methods to safely access and modify these attributes
- Include validation for age (0-100) and grades (0-100)
- Create a
-
Property Practice
- Create a
Rectangleclass using properties for width and height - Add computed properties for area and perimeter
- Ensure width and height are always positive
- Create a
-
Bank Account System
- Create a
BankAccountclass with encapsulated balance - Add methods for deposit, withdraw, and transfer
- Include transaction history and account validation
- Create a
-
Temperature Converter
- Create a
Temperatureclass that stores Celsius internally - Add properties for Fahrenheit and Kelvin conversion
- Include validation for absolute zero
- Create a
-
Custom Descriptor
- Create a
TypedAttributedescriptor that enforces type checking - Use it in a
Personclass for name (str) and age (int) - Add optional default values and custom error messages
- Create a
-
Observable Pattern
- Create an
ObservableListclass that notifies observers of changes - Implement add, remove, and clear methods
- Allow multiple observers with different notification preferences
- Create an
-
Lazy Loading System
- Create a
LazyLoaderdescriptor for expensive computations - Add cache invalidation based on dependent attributes
- Implement thread-safe lazy loading
- Create a
-
Configuration Manager
- Create a singleton
Configclass with encapsulated settings - Support nested configuration with dot notation access
- Add validation, type checking, and change notifications
- Create a singleton
Encapsulation is a powerful principle that helps you:
- Protect data from unauthorized access and modification
- Maintain code quality through controlled interfaces
- Enable validation and business rule enforcement
- Improve maintainability by hiding implementation details
- Provide clear APIs for other developers
- Use appropriate access levels: Public for APIs, protected for internal use, private for sensitive data
- Leverage properties: They provide clean syntax with powerful functionality
- Validate data: Always validate inputs in setters and methods
- Be consistent: Follow the same patterns throughout your codebase
- Don't over-encapsulate: Balance protection with usability
- Practice with the provided exercises
- Explore the example files in this directory
- Study real-world codebases to see encapsulation in action
- Experiment with advanced techniques like descriptors and metaclasses
Remember: Encapsulation is not about hiding everything—it's about providing the right level of access for each piece of data and functionality in your system.