Funciones bien hechas
Domina el arte de escribir funciones pequeñas y enfocadas, con responsabilidad única, parámetros adecuados y sin efectos secundarios
Las funciones son los bloques de construcción fundamentales del código legible y mantenible. Una función bien construida cuenta una historia clara sobre qué hace, qué necesita y qué devuelve.
Robert C. Martin afirma: "La primera regla de las funciones es que deberían ser pequeñas. La segunda regla de las funciones es que deberían ser aún más pequeñas."
La primera regla: funciones pequeñas
Una función debe hacer una cosa, hacerla bien y hacer solo eso. Si puedes extraer de ella otra función con un nombre significativo, está haciendo demasiado.
# 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"}Principio de responsabilidad única
Una función debe tener una sola razón para cambiar. Si una función mezcla lógica de negocio, E/S y presentación, viola el SRP.
# 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 reportParámetros de función
Minimiza los parámetros
Una función sin parámetros es la más fácil de entender. Uno o dos parámetros está bien. Tres deben justificarse. Más de tres sugiere un problema de diseño.
# 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:
passEvita los parámetros bandera
Los booleanos en las firmas de funciones indican que la función hace dos cosas.
# 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):
passPrefiere los argumentos con nombre
Los argumentos con nombre hacen que las llamadas a las funciones se autodocumenten.
# Unclear positional args
connect("localhost", 5432, "admin", "secret", "dbname")
# Clear keyword args
connect(
host="localhost",
port=5432,
username="admin",
password="secret",
database="dbname",
)Efectos secundarios
Una función debe hacer algo (comando) o responder algo (consulta), pero no ambas cosas. Los efectos secundarios son cambios ocultos en el estado.
# 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# 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]Separación entre comandos y consultas
Las funciones deben ser comandos (realizan una acción) o consultas (devuelven datos), no ambas.
# 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()Estructura y flujo de una función
La regla del escalonamiento
Cada función debe ir seguida de funciones en el siguiente nivel de abstracción. Esto crea una narrativa que se lee de arriba hacia abajo.
# 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}",
)Manejo de errores
El manejo de errores es una cosa más. Una función que maneja errores no debe hacer nada más.
# 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 0Funciones puras frente a funciones impuras
Aspecto | Función pura | Función impura |
|---|---|---|
Misma entrada → misma salida | Siempre | No garantizado |
Efectos secundarios | Ninguno | Puede tener |
Pruebas | Triviales | Requiere mocking |
Segura para hilos | Sí | Depende |
Cacheable | Sí | No |
Ejemplo | sum(a, b) | print(message) |
# 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()Ejemplo del mundo real: refactorizar un monolito
Antes: una única función de 80 líneas que gestionaba el procesamiento de pagos.
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"]}, 500Después: funciones limpias y enfocadas.
@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}, 200Funciones y pruebas
Las funciones limpias son fáciles de probar. Cada función puede probarse de forma aislada.
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 NoneFlujo de decisión sobre funciones
Un error común es hacer las funciones "un poquito más grandes" para ahorrar unas cuantas líneas. Resiste a eso. Cada línea adicional reduce la legibilidad y aumenta la probabilidad de bugs.
Las funciones pequeñas y enfocadas son la base del código limpio. Practica extraer funciones hasta que se te haga costumbre.
Ejercicios prácticos
-
Extrae sin parar: Toma una función de 100 líneas y sigue extrayendo hasta que ninguna función tenga más de 10 líneas.
-
Eliminación de banderas: Encuentra una función con un parámetro booleano y divídela en dos funciones con nombre propio.
-
Auditoría de parámetros: Encuentra una función con 4 o más parámetros. Agrupa los parámetros relacionados en una dataclass.
-
Caza de efectos secundarios: Encuentra una función que modifique el estado global o sus entradas. Refactorízala para que sea pura (o declara explícitamente los efectos secundarios).
-
Violación de CQS: Encuentra una función que a la vez devuelva datos y modifique el estado. Divídela en un comando y una consulta.
-
Estructura escalonada: Toma una función compleja y reorganízala para que la lógica de alto nivel se lea primero, seguida de las funciones auxiliares.
-
Refactorización SRP: Encuentra una clase con un método grande. Extrae métodos más pequeños y enfocados hasta que la clase cumpla el principio de responsabilidad única.
-
Refactorización guiada por pruebas: Antes de refactorizar una función grande, escribe pruebas que capturen su comportamiento. Refactoriza y luego verifica que todas las pruebas siguen pasando.