Encapsulation: Protecting Your Data
Welcome to Encapsulation! Think of it as putting your code in a protective capsule - hiding internal details and controlling access to your object’s data.
What is Encapsulation?
Encapsulation is about bundling data and methods together and restricting access to internal details. It’s like a black box - you know what it does, but not how it works internally.
Without Encapsulation (Dangerous!)
class BankAccount:
def __init__(self, balance):
self.balance = balance # Anyone can change this directly!
account = BankAccount(1000)
account.balance = -500 # This should not be allowed!
print(account.balance) # -500 - Oh no!
With Encapsulation (Safe!)
class BankAccount:
def __init__(self, balance):
self._balance = balance # "Private" attribute
def get_balance(self):
return self._balance
def deposit(self, amount):
if amount > 0:
self._balance += amount
def withdraw(self, amount):
if 0 < amount <= self._balance:
self._balance -= amount
account = BankAccount(1000)
account.deposit(500)
account.withdraw(200)
print(account.get_balance()) # 1300 - Safe!
# account._balance = -500 # Still possible but discouraged
Public vs Private Attributes
Naming Conventions
class Person:
def __init__(self, name, age, salary):
self.name = name # Public attribute
self._age = age # Protected attribute (convention)
self.__salary = salary # Private attribute (name mangling)
def get_age(self):
return self._age
def set_age(self, age):
if 0 <= age <= 150:
self._age = age
def get_salary(self):
return self.__salary
def give_raise(self, amount):
if amount > 0:
self.__salary += amount
# Test
person = Person("Alice", 30, 50000)
print(person.name) # "Alice" - Public access
print(person.get_age()) # 30 - Controlled access
print(person.get_salary()) # 50000 - Controlled access
# Direct access (discouraged)
print(person._age) # 30 - Convention only
# print(person.__salary) # AttributeError - Name mangled to _Person__salary
print(person._Person__salary) # 50000 - But don't do this!
Name Mangling
class Example:
def __init__(self):
self.__private = "secret"
self._protected = "semi-secret"
self.public = "open"
obj = Example()
print(obj.public) # "open"
print(obj._protected) # "semi-secret" (convention)
# print(obj.__private) # AttributeError
print(obj._Example__private) # "secret" (name mangling)
Property Decorators
The @property Decorator
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
"""Get temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Set temperature in Celsius with validation."""
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
"""Get temperature in Fahrenheit."""
return (self._celsius * 9/5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Set temperature in Fahrenheit."""
celsius = (value - 32) * 5/9
self.celsius = celsius # Uses the celsius setter for validation
# Test
temp = Temperature(20)
print(f"Celsius: {temp.celsius}") # 20
print(f"Fahrenheit: {temp.fahrenheit}") # 68.0
temp.celsius = 25
print(f"New Celsius: {temp.celsius}") # 25
temp.fahrenheit = 100
print(f"After setting Fahrenheit: {temp.celsius}") # 37.777...
try:
temp.celsius = -300 # Below absolute zero
except ValueError as e:
print(f"Error: {e}")
Read-Only Properties
class 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 property - calculated from radius."""
return 3.14159 * self._radius ** 2
@property
def circumference(self):
"""Read-only property."""
return 2 * 3.14159 * self._radius
# Test
circle = Circle(5)
print(f"Radius: {circle.radius}") # 5
print(f"Area: {circle.area:.2f}") # 78.54
print(f"Circumference: {circle.circumference:.2f}") # 31.42
circle.radius = 10
print(f"New area: {circle.area:.2f}") # 314.16
# circle.area = 100 # AttributeError - can't set read-only property
Data Validation and Business Rules
Validation in Setters
class Email:
def __init__(self, address):
self.address = address # Uses setter
@property
def address(self):
return self._address
@address.setter
def address(self, value):
if not isinstance(value, str):
raise TypeError("Email must be a string")
if "@" not in value:
raise ValueError("Invalid email format")
if value.count("@") != 1:
raise ValueError("Email must contain exactly one @")
self._address = value.lower().strip()
class User:
def __init__(self, name, email, age):
self.name = name
self.email = email
self.age = age
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value or not value.strip():
raise ValueError("Name cannot be empty")
if len(value.strip()) < 2:
raise ValueError("Name must be at least 2 characters")
self._name = value.strip().title()
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int):
raise TypeError("Age must be an integer")
if value < 0:
raise ValueError("Age cannot be negative")
if value > 150:
raise ValueError("Age seems unreasonably high")
self._age = value
# Test
try:
user = User("alice", "alice@example.com", 25)
print(f"User: {user.name}, Email: {user.email}, Age: {user.age}")
user.name = "bob" # Valid change
user.age = 30 # Valid change
user.name = "" # Invalid - empty name
except ValueError as e:
print(f"Validation error: {e}")
Business Logic Encapsulation
class BankAccount:
def __init__(self, account_number, owner, initial_balance=0):
self._account_number = account_number
self._owner = owner
self._balance = initial_balance
self._transactions = []
@property
def account_number(self):
return self._account_number
@property
def owner(self):
return self._owner
@property
def balance(self):
return self._balance
def deposit(self, amount):
"""Deposit money with validation."""
if not isinstance(amount, (int, float)):
raise TypeError("Amount must be a number")
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
self._record_transaction("deposit", amount)
def withdraw(self, amount):
"""Withdraw money with business rules."""
if not isinstance(amount, (int, float)):
raise TypeError("Amount must be a number")
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
# Business rule: Maximum withdrawal of $1000 per transaction
if amount > 1000:
raise ValueError("Maximum withdrawal is $1000")
self._balance -= amount
self._record_transaction("withdrawal", amount)
def transfer(self, target_account, amount):
"""Transfer money to another account."""
# Validate target account
if not isinstance(target_account, BankAccount):
raise TypeError("Target must be a BankAccount")
# Use existing withdrawal logic
self.withdraw(amount)
target_account.deposit(amount)
self._record_transaction("transfer_out", amount, target_account.account_number)
target_account._record_transaction("transfer_in", amount, self.account_number)
def _record_transaction(self, transaction_type, amount, other_account=None):
"""Record a transaction internally."""
transaction = {
"type": transaction_type,
"amount": amount,
"balance_after": self._balance,
"other_account": other_account
}
self._transactions.append(transaction)
def get_transaction_history(self):
"""Get transaction history (read-only)."""
return self._transactions.copy() # Return copy to prevent modification
# Test
account1 = BankAccount("12345", "Alice", 1000)
account2 = BankAccount("67890", "Bob", 500)
account1.deposit(200)
account1.withdraw(150)
account1.transfer(account2, 100)
print(f"Account 1 balance: ${account1.balance}")
print(f"Account 2 balance: ${account2.balance}")
print("Account 1 transactions:")
for tx in account1.get_transaction_history():
print(f" {tx['type']}: ${tx['amount']} (Balance: ${tx['balance_after']})")
Class Variables and Methods
Class Variables with Encapsulation
class Employee:
# Class variables (shared by all instances)
_company_name = "Tech Corp"
_total_employees = 0
_next_id = 1000
def __init__(self, name, salary):
self._id = Employee._next_id
Employee._next_id += 1
self._name = name
self._salary = salary
Employee._total_employees += 1
@classmethod
def get_total_employees(cls):
"""Class method to get total employees."""
return cls._total_employees
@classmethod
def set_company_name(cls, name):
"""Class method to set company name."""
cls._company_name = name
@classmethod
def get_company_name(cls):
"""Class method to get company name."""
return cls._company_name
@property
def id(self):
return self._id
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value.strip():
raise ValueError("Name cannot be empty")
self._name = value.strip()
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError("Salary cannot be negative")
self._salary = value
# Test
emp1 = Employee("Alice", 50000)
emp2 = Employee("Bob", 60000)
print(f"Total employees: {Employee.get_total_employees()}")
print(f"Company: {Employee.get_company_name()}")
Employee.set_company_name("Super Tech Corp")
print(f"New company: {Employee.get_company_name()}")
Practical Examples
Example 1: Password Manager
import hashlib
import secrets
class PasswordManager:
def __init__(self):
self._passwords = {} # service -> hashed_password
self._master_password = None
self._salt = secrets.token_hex(16)
def set_master_password(self, password):
"""Set master password with hashing."""
if len(password) < 8:
raise ValueError("Master password must be at least 8 characters")
self._master_password = self._hash_password(password)
def _hash_password(self, password):
"""Hash a password with salt."""
salted = password + self._salt
return hashlib.sha256(salted.encode()).hexdigest()
def _verify_master_password(self, password):
"""Verify master password."""
return self._hash_password(password) == self._master_password
def add_password(self, service, password, master_password):
"""Add a password for a service."""
if not self._verify_master_password(master_password):
raise ValueError("Invalid master password")
if service in self._passwords:
raise ValueError(f"Password for {service} already exists")
self._passwords[service] = self._hash_password(password)
print(f"Password added for {service}")
def get_password(self, service, master_password):
"""Retrieve a password for a service."""
if not self._verify_master_password(master_password):
raise ValueError("Invalid master password")
if service not in self._passwords:
raise ValueError(f"No password found for {service}")
return self._passwords[service]
def list_services(self, master_password):
"""List all services."""
if not self._verify_master_password(master_password):
raise ValueError("Invalid master password")
return list(self._passwords.keys())
# Test
pm = PasswordManager()
pm.set_master_password("mysecretpassword")
pm.add_password("gmail", "gmailpass123", "mysecretpassword")
pm.add_password("github", "githubpass456", "mysecretpassword")
print("Services:", pm.list_services("mysecretpassword"))
# This would fail
try:
pm.get_password("gmail", "wrongpassword")
except ValueError as e:
print(f"Error: {e}")
Example 2: Configuration Manager
import json
import os
from pathlib import Path
class ConfigManager:
def __init__(self, config_file="config.json"):
self._config_file = Path(config_file)
self._config = self._load_config()
self._defaults = {
"debug": False,
"max_connections": 10,
"timeout": 30,
"theme": "light"
}
def _load_config(self):
"""Load configuration from file."""
if not self._config_file.exists():
return self._defaults.copy()
try:
with open(self._config_file, "r") as f:
config = json.load(f)
# Merge with defaults for missing keys
merged = self._defaults.copy()
merged.update(config)
return merged
except (json.JSONDecodeError, IOError):
print("Warning: Could not load config file, using defaults")
return self._defaults.copy()
def _save_config(self):
"""Save configuration to file."""
try:
self._config_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_file, "w") as f:
json.dump(self._config, f, indent=2)
except IOError as e:
print(f"Warning: Could not save config: {e}")
@property
def debug(self):
return self._config["debug"]
@debug.setter
def debug(self, value):
if not isinstance(value, bool):
raise TypeError("Debug must be a boolean")
self._config["debug"] = value
self._save_config()
@property
def max_connections(self):
return self._config["max_connections"]
@max_connections.setter
def max_connections(self, value):
if not isinstance(value, int) or value < 1:
raise ValueError("Max connections must be a positive integer")
self._config["max_connections"] = value
self._save_config()
@property
def timeout(self):
return self._config["timeout"]
@timeout.setter
def timeout(self, value):
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError("Timeout must be a positive number")
self._config["timeout"] = value
self._save_config()
@property
def theme(self):
return self._config["theme"]
@theme.setter
def theme(self, value):
valid_themes = ["light", "dark", "auto"]
if value not in valid_themes:
raise ValueError(f"Theme must be one of: {valid_themes}")
self._config["theme"] = value
self._save_config()
def get_all_settings(self):
"""Get all settings as a dictionary."""
return self._config.copy()
def reset_to_defaults(self):
"""Reset all settings to defaults."""
self._config = self._defaults.copy()
self._save_config()
# Test
config = ConfigManager()
print("Initial settings:")
print(config.get_all_settings())
config.debug = True
config.max_connections = 20
config.theme = "dark"
print("\nAfter changes:")
print(config.get_all_settings())
try:
config.theme = "invalid"
except ValueError as e:
print(f"Error: {e}")
Example 3: Inventory System
class InventoryItem:
def __init__(self, item_id, name, price, quantity=0):
self._item_id = item_id
self._name = name
self._price = price
self._quantity = quantity
@property
def item_id(self):
return self._item_id
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value.strip():
raise ValueError("Name cannot be empty")
self._name = value.strip()
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)) or value < 0:
raise ValueError("Price must be a non-negative number")
self._price = value
@property
def quantity(self):
return self._quantity
@quantity.setter
def quantity(self, value):
if not isinstance(value, int) or value < 0:
raise ValueError("Quantity must be a non-negative integer")
self._quantity = value
@property
def total_value(self):
"""Calculated property - read-only."""
return self._price * self._quantity
def add_stock(self, amount):
"""Add to inventory."""
if amount <= 0:
raise ValueError("Amount must be positive")
self.quantity = self._quantity + amount
def remove_stock(self, amount):
"""Remove from inventory."""
if amount <= 0:
raise ValueError("Amount must be positive")
if amount > self._quantity:
raise ValueError("Insufficient stock")
self.quantity = self._quantity - amount
class Inventory:
def __init__(self):
self._items = {} # item_id -> InventoryItem
def add_item(self, item):
"""Add an item to inventory."""
if item.item_id in self._items:
raise ValueError(f"Item {item.item_id} already exists")
self._items[item.item_id] = item
def get_item(self, item_id):
"""Get an item by ID."""
if item_id not in self._items:
raise ValueError(f"Item {item_id} not found")
return self._items[item_id]
def update_quantity(self, item_id, new_quantity):
"""Update item quantity."""
item = self.get_item(item_id)
item.quantity = new_quantity
@property
def total_value(self):
"""Total value of all inventory."""
return sum(item.total_value for item in self._items.values())
def get_low_stock_items(self, threshold=5):
"""Get items with low stock."""
return [item for item in self._items.values() if item.quantity <= threshold]
# Test
inventory = Inventory()
item1 = InventoryItem("001", "Laptop", 999.99, 10)
item2 = InventoryItem("002", "Mouse", 25.50, 50)
inventory.add_item(item1)
inventory.add_item(item2)
print(f"Total inventory value: ${inventory.total_value:.2f}")
item1.add_stock(5)
item2.remove_stock(10)
print(f"Low stock items: {[item.name for item in inventory.get_low_stock_items()]}")
Practice Exercises
Exercise 1: Student Grade Manager
Create a Student class with:
- Encapsulated grades list
- Methods to add grades with validation
- Property for average grade (read-only)
- Property for letter grade (calculated)
Exercise 2: Secure Password Validator
Build a PasswordValidator class that:
- Validates password strength (length, complexity)
- Uses properties for different validation rules
- Provides feedback on password requirements
- Tracks validation attempts
Exercise 3: Library Book System
Create a LibraryBook class with:
- Encapsulated borrowing status
- Methods to borrow/return with validation
- Property for due date calculation
- Property for overdue status
Exercise 4: Shopping Cart with Validation
Build a ShoppingCart class that:
- Validates item additions (price, quantity)
- Encapsulated item list
- Properties for subtotal, tax, total
- Methods to add/remove items safely
Exercise 5: User Authentication System
Create a UserAuth class that:
- Encapsulates password hashing
- Validates login attempts
- Tracks failed login attempts
- Implements account lockout after failures
Summary
Encapsulation protects and controls access to your object’s data:
Access Levels:
public- Direct access (e.g.,self.name)_protected- Convention only (e.g.,self._age)__private- Name mangling (e.g.,self.__salary)
Property Decorators:
@property
def attribute(self):
return self._attribute
@attribute.setter
def attribute(self, value):
# validation logic
self._attribute = value
Benefits:
- Data protection and validation
- Controlled access to internal state
- Ability to change implementation without breaking users
- Clear interface between public and private
Best Practices:
- Use properties for validated attributes
- Keep implementation details private
- Provide clear, controlled public interface
- Validate all inputs in setters
Next: Inheritance - reusing code from parent classes! 📈