intermediate50 minutesLesson 3 of 8

Functions Done Right

Master the art of writing small, focused functions with single responsibility, proper parameters, and no side effects

Functions Done Right

Functions are the fundamental building blocks of readable, maintainable code. A well-crafted function tells a clear story about what it does, what it needs, and what it returns.

ℹ️Note

Robert C. Martin states: "The first rule of functions is that they should be small. The second rule of functions is that they should be smaller than that."

The First Rule: Small Functions

A function should do one thing, do it well, and do it only. If you can extract another function from it with a meaningful name, it is doing too much.

python
# Too large: this function does everything def process_order(order_data: dict) -> dict: # Validate if not order_data.get("items"): raise ValueError("Order must have items") if sum(item["price"] for item in order_data["items"]) <= 0: raise ValueError("Total must be positive") # Calculate subtotal = sum(item["price"] * item["quantity"] for item in order_data["items"]) tax = subtotal * 0.08 shipping = 5.99 if subtotal < 50 else 0 total = subtotal + tax + shipping # Save with open("orders.txt", "a") as f: f.write(f"{order_data['customer']}:{total}\n") # Notify print(f"Order confirmed for {order_data['customer']}") return {"total": total, "status": "confirmed"} # Refactored: each function does one thing def validate_order(order_data: dict) -> None: if not order_data.get("items"): raise ValueError("Order must have items") if sum(item["price"] for item in order_data["items"]) <= 0: raise ValueError("Total must be positive") def calculate_total(order_data: dict) -> dict: subtotal = sum(item["price"] * item["quantity"] for item in order_data["items"]) tax = subtotal * TAX_RATE shipping = SHIPPING_COST if subtotal < FREE_SHIPPING_THRESHOLD else 0 total = subtotal + tax + shipping return {"subtotal": subtotal, "tax": tax, "shipping": shipping, "total": total} def save_order(order_data: dict, totals: dict) -> None: with open("orders.txt", "a") as f: f.write(f"{order_data['customer']}:{totals['total']}\n") def notify_customer(customer_name: str) -> None: print(f"Order confirmed for {customer_name}") def process_order(order_data: dict) -> dict: validate_order(order_data) totals = calculate_total(order_data) save_order(order_data, totals) notify_customer(order_data["customer"]) return {**totals, "status": "confirmed"}

Single Responsibility Principle

A function should have one reason to change. If a function mixes business logic, I/O, and presentation, it violates SRP.

python
# Violates SRP: mixes calculation, formatting, and I/O def generate_salary_report(employees: list): total = sum(e.salary for e in employees) avg = total / len(employees) report = f"Total: ${total}\nAverage: ${avg}\n" with open("salary_report.txt", "w") as f: f.write(report) return report # Follows SRP: each function has one responsibility def calculate_salary_stats(employees: list) -> dict: total = sum(e.salary for e in employees) return {"total": total, "average": total / len(employees), "count": len(employees)} def format_salary_report(stats: dict) -> str: return f"Total: ${stats['total']:.2f}\nAverage: ${stats['average']:.2f}\nCount: {stats['count']}" def write_report_to_file(report: str, filename: str) -> None: with open(filename, "w") as f: f.write(report) def generate_salary_report(employees: list, filename: str) -> str: stats = calculate_salary_stats(employees) report = format_salary_report(stats) write_report_to_file(report, filename) return report

Function Parameters

Minimize Parameters

A function with no parameters is the easiest to understand. One or two parameters is good. Three should be justified. More than three suggests a design problem.

python
# Too many parameters def create_user(name, email, age, address, phone, is_admin, notify): pass # Group related params into an object @dataclass class UserProfile: name: str email: str age: int address: str phone: str def create_user(profile: UserProfile, is_admin: bool = False, notify: bool = True) -> User: pass

Avoid Flag Parameters

Boolean flags in function signatures indicate the function does two things.

python
# Flag parameter: two behaviors def send_email(to: str, message: str, is_urgent: bool): if is_urgent: # Send immediately pass else: # Add to queue pass # Better: two functions def send_immediate_email(to: str, message: str): pass def queue_email(to: str, message: str): pass

Prefer Keyword Arguments

Keyword arguments make function calls self-documenting.

python
# Unclear positional args connect("localhost", 5432, "admin", "secret", "dbname") # Clear keyword args connect( host="localhost", port=5432, username="admin", password="secret", database="dbname", )

Side Effects

A function should either do something (command) or answer something (query), but not both. Side effects are hidden changes to state.

python
# Side effect: modifies global state DISCOUNT_MULTIPLIER = 1.0 def apply_discount(price: float) -> float: global DISCOUNT_MULTIPLIER DISCOUNT_MULTIPLIER = 0.9 # Side effect! return price * DISCOUNT_MULTIPLIER # Pure function: predictable, no side effects def apply_discount(price: float, discount_multiplier: float = 1.0) -> float: return price * discount_multiplier
python
# Side effect: modifies input def add_to_cart(cart: list, item: dict) -> list: cart.append(item) # Modifies the original list return cart # Explicit: returns new state def add_to_cart(cart: list, item: dict) -> list: return [*cart, item]

Command-Query Separation

Functions should either be commands (perform an action) or queries (return data), not both.

python
# Violates CQS: both modifies and queries def pop_item(stack: list): if not stack: return None return stack.pop() # Follows CQS def is_empty(stack: list) -> bool: return len(stack) == 0 def pop_item(stack: list): if is_empty(stack): return None return stack.pop()

Function Structure and Flow

100%

The Stepdown Rule

Every function should be followed by functions at the next level of abstraction. This creates a narrative that reads top-to-bottom.

python
# High-level story def process_payment(order: Order) -> PaymentResult: validate_order(order) charge_customer(order) send_receipt(order) return PaymentResult.success() # Next level of detail def validate_order(order: Order) -> None: if not order.items: raise ValueError("Empty order") if order.total <= 0: raise ValueError("Invalid total") def charge_customer(order: Order) -> None: gateway = PaymentGateway(order.payment_method) gateway.charge(order.total) def send_receipt(order: Order) -> None: email = EmailService() email.send( to=order.customer_email, subject="Your Receipt", body=f"Total charged: ${order.total:.2f}", )

Error Handling

Error handling is one thing. A function that handles errors should do nothing else.

python
# Mixed: error handling with business logic def get_user_score(user_id: int) -> int: try: user = database.find_user(user_id) score = calculate_score(user) return score except DatabaseError: return 0 except ScoreError: return 0 # Clean: separate error handling from logic def get_user_score(user_id: int) -> int: try: user = database.find_user(user_id) return calculate_score(user) except (DatabaseError, ScoreError): return 0

Pure Functions vs Impure Functions

AspectPure FunctionImpure Function
Same input → same outputAlwaysNot guaranteed
Side effectsNoneMay have
TestingTrivialRequires mocking
Thread-safeYesDepends
CachableYesNo
Examplesum(a, b)print(message)
python
# Pure function def add_to_date(start_date: date, days: int) -> date: return start_date + timedelta(days=days) # Impure function (relies on external state) def get_current_timestamp() -> datetime: return datetime.now()

Real-World Example: Refactoring a Monolith

Before: A single 80-line function handling payment processing.

python
def handle_payment(request): # Parse input data = json.loads(request.body) user_id = data["user_id"] amount = data["amount"] currency = data.get("currency", "USD") method = data["method"] # Validate user user = db.query(f"SELECT * FROM users WHERE id = {user_id}") if not user: return {"error": "User not found"}, 404 if user["balance"] < amount: return {"error": "Insufficient funds"}, 400 # Process payment if method == "credit_card": result = credit_card_charge(user["card_token"], amount, currency) elif method == "paypal": result = paypal_charge(user["paypal_email"], amount, currency) else: return {"error": "Unknown method"}, 400 if result["status"] == "success": db.query(f"UPDATE users SET balance = balance - {amount} WHERE id = {user_id}") send_email(user["email"], "Payment received", f"Amount: {amount}") return {"status": "ok", "transaction_id": result["id"]}, 200 else: return {"error": result["message"]}, 500

After: Clean, focused functions.

python
@dataclass class PaymentRequest: user_id: int amount: Decimal currency: str method: str @dataclass class PaymentResult: success: bool transaction_id: Optional[str] = None error_message: Optional[str] = None def parse_payment_request(body: str) -> PaymentRequest: data = json.loads(body) return PaymentRequest( user_id=data["user_id"], amount=Decimal(str(data["amount"])), currency=data.get("currency", "USD"), method=data["method"], ) def validate_payment(user: User, request: PaymentRequest) -> Optional[str]: if user.balance < request.amount: return "Insufficient funds" return None def process_payment(user: User, request: PaymentRequest) -> PaymentResult: processors = { "credit_card": lambda: charge_credit_card(user.card_token, request), "paypal": lambda: charge_paypal(user.paypal_email, request), } processor = processors.get(request.method) if not processor: return PaymentResult(success=False, error_message="Unknown method") return processor() def charge_credit_card(token: str, request: PaymentRequest) -> PaymentResult: gateway = CreditCardGateway(token) result = gateway.charge(request.amount, request.currency) return PaymentResult(success=result.ok, transaction_id=result.id) def charge_paypal(email: str, request: PaymentRequest) -> PaymentResult: gateway = PayPalGateway(email) result = gateway.charge(request.amount, request.currency) return PaymentResult(success=result.ok, transaction_id=result.id) def deduct_balance(user: User, amount: Decimal) -> None: user.balance -= amount def notify_user(user: User, amount: Decimal) -> None: send_email(user.email, "Payment received", f"Amount: {amount}") def handle_payment(request_body: str) -> tuple: payment_request = parse_payment_request(request_body) user = find_user(payment_request.user_id) if not user: return {"error": "User not found"}, 404 error = validate_payment(user, payment_request) if error: return {"error": error}, 400 result = process_payment(user, payment_request) if not result.success: return {"error": result.error_message}, 500 deduct_balance(user, payment_request.amount) notify_user(user, payment_request.amount) return {"status": "ok", "transaction_id": result.transaction_id}, 200

Functions and Testing

Clean functions are easy to test. Each function can be tested in isolation.

python
import pytest def test_parse_payment_request(): body = '{"user_id": 1, "amount": "100.00", "method": "credit_card"}' result = parse_payment_request(body) assert result.user_id == 1 assert result.amount == Decimal("100.00") def test_validate_payment_insufficient_funds(): user = User(balance=Decimal("50.00")) request = PaymentRequest(amount=Decimal("100.00"), ...) error = validate_payment(user, request) assert error == "Insufficient funds" def test_validate_payment_sufficient_funds(): user = User(balance=Decimal("200.00")) request = PaymentRequest(amount=Decimal("100.00"), ...) error = validate_payment(user, request) assert error is None

Function Decision Flow

100%
⚠️Warning

A common mistake is making functions "just a little bit bigger" to save a few lines. Resist this. Every additional line decreases readability and increases the chance of bugs.

Success

Small, focused functions are the foundation of clean code. Practice extracting functions until it becomes second nature.

Practice Exercises

  1. Extract till you drop: Take a 100-line function and keep extracting until no function is longer than 10 lines.

  2. Flag removal: Find a function with a boolean parameter and split it into two named functions.

  3. Parameter audit: Find a function with 4+ parameters. Group related parameters into a dataclass.

  4. Side effect hunt: Find a function that modifies global state or its inputs. Refactor it to be pure (or explicitly state side effects).

  5. CQS violation: Find a function that both returns data and modifies state. Split it into a command and a query.

  6. Stepdown structure: Take a complex function and reorganize it so high-level logic reads first, followed by helper functions.

  7. SRP refactor: Find a class with one large method. Extract smaller, focused methods until the class follows the single responsibility principle.

  8. Test-driven refactor: Before refactoring a large function, write tests that capture its behavior. Refactor, then verify all tests still pass.

Progress38%