Funciones: una tarea, pocos parámetros, sin efectos secundarios ocultos
En resumen: Una buena función hace una tarea en un nivel de abstracción, tiene pocos parámetros y ningún flag booleano, y no cambia en silencio un estado que su nombre no promete.
Problema
La función que lo hace todo
Los nombres de la lección anterior ya están puestos. Las constantes de la tienda:
FREE_DELIVERY_FROM = 50STANDARD_DELIVERY_FEE = 4.99EXPRESS_SURCHARGE = 7SPRING_COUPON = "SPRING10"SPRING_DISCOUNT = 0.10
orders = []Y esta es la función por la que pasa cada pedido:
def process_order(email, prices, coupon, express): amount = 0 for price in prices: amount += price if coupon == SPRING_COUPON: amount = amount * (1 - SPRING_DISCOUNT) delivery = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: delivery += EXPRESS_SURCHARGE total = amount + delivery orders.append(total) print(f"Para {email}: pedido confirmado, {total:.2f} €") print(f"[log] pedido n.º {len(orders)} por {total:.2f} €")process_order("maria@example.com", [24.50, 18.00], "SPRING10", True)process_order("ivan@example.com", [64.00], "", False)Para maria@example.com: pedido confirmado, 50.24 €[log] pedido n.º 1 por 50.24 €Para ivan@example.com: pedido confirmado, 64.00 €[log] pedido n.º 2 por 64.00 €Funciona. Ahora marketing quiere que el cupón no valga para el envío. Contabilidad quiere ver la tarifa por separado en el carrito. La persona que prueba quiere comprobar el envío por debajo y por encima de 50 €, y para cada comprobación tiene que hacer un pedido, es decir, guardarlo y mandar un correo. Todos tocan la misma función. Y qué significa True al final de la llamada, sigue sin verse.
La idea
Una tarea y un nivel
Intenta decir qué hace process_order: «suma los precios, y aplica el cupón, y calcula el envío, y guarda, y manda un correo, y escribe en el log». Cada «y» es una tarea aparte. Cada una cambia por su propia razón y por una persona distinta.
La regla es una tarea por función. Una función con una tarea es pequeña, tiene un nombre que dice toda la tarea y pocos parámetros. Así sabes que la tarea es una:
- la describes en una frase sin «y»;
- no puedes sacar de ella un trozo con un nombre que no sea solo un recuento del código.
La segunda regla es un nivel de abstracción. La función de arriba se lee como el índice de un libro: calcular el total, guardar, confirmar, registrar. Cómo se suman exactamente los precios (los bucles, las comparaciones, el formato de la cadena) está un nivel más abajo, en las funciones pequeñas. Cuando «guardar el pedido» y amount += price están en el mismo método, el lector salta entre los dos niveles en cada línea.
Míralo
El mismo pedido, dividido
Arriba está el índice:
def place_order(email, prices, coupon, express): total = order_total(prices, coupon, express) orders.append(total) send_confirmation(email, total) log_order(len(orders), total)
def send_confirmation(email, total): print(f"Para {email}: pedido confirmado, {total:.2f} €")
def log_order(number, total): print(f"[log] pedido n.º {number} por {total:.2f} €")Debajo viene el cálculo, cada tarea en su propia función:
def subtotal(prices): amount = 0 for price in prices: amount += price return amount
def apply_coupon(amount, coupon): if coupon == SPRING_COUPON: return amount * (1 - SPRING_DISCOUNT) return amount
def delivery_fee(amount, express): fee = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: fee += EXPRESS_SURCHARGE return feedef order_total(prices, coupon, express): amount = subtotal(prices) discounted = apply_coupon(amount, coupon) return discounted + delivery_fee(discounted, express)place_order("maria@example.com", [24.50, 18.00], "SPRING10", True)place_order("ivan@example.com", [64.00], "", False)Para maria@example.com: pedido confirmado, 50.24 €[log] pedido n.º 1 por 50.24 €Para ivan@example.com: pedido confirmado, 64.00 €[log] pedido n.º 2 por 64.00 €La salida es la misma. Ahora el cupón está en una función de tres líneas, el envío en otra, y cada una cambia sin tocar el correo. Hay más líneas de código, pero cada pieza se lee, se cambia y se comprueba por separado.
Más precisamente, para curiosos
¿Cuán pequeña? No hay un número. Una función de 30 líneas que hace una sola cosa (por ejemplo un switch largo pero plano con ramas parecidas) está bien. Una función de 8 líneas con tres niveles de anidamiento y dos flags no. La medida es la descripción en una frase, no el número de líneas. Dividir una función larga en pasos, de modo que el programa funcione después de cada uno, es Extract Method, en la sexta lección de este módulo.
Pruébalo
Pon las líneas en orden
Pon order_total en orden para que el cupón se aplique antes del envío. Por qué importa el orden: el descuento puede bajar el pedido de 50 €, y entonces el envío ya no es gratis.
return discounted + delivery_fee(discounted, express)discounted = apply_coupon(amount, coupon)amount = subtotal(prices)def order_total(prices, coupon, express):
La idea
Pocos parámetros y sin flags
Cada parámetro es algo que el lector tiene que recordar y que cada llamada tiene que pasar en el orden correcto. Cero, uno y dos parámetros se leen fácil. Tres ya son muchos. Cuatro o más suelen significar que los parámetros son una sola cosa partida en trozos: el correo, los precios, el cupón y el envío juntos son un pedido. Entonces pasan a ser un objeto. Es el refactor Introduce Parameter Object de la sexta lección.
El peor parámetro es un flag: un booleano que elige lo que hace la función.
def print_slip(prices, for_courier): if for_courier: print(f"Paquete: {len(prices)} uds.") else: for price in prices: print(f"{price:.2f} €")cart = [24.50, 18.00]print_slip(cart, True)print_slip(cart, False)Paquete: 2 uds.24.50 €18.00 €En la llamada, True no dice nada. Y por dentro la función tiene dos caminos separados, o sea, dos tareas. La solución son dos funciones con nombre:
def print_courier_slip(prices): print(f"Paquete: {len(prices)} uds.")
def print_receipt(prices): for price in prices: print(f"{price:.2f} €")cart = [24.50, 18.00]print_courier_slip(cart)print_receipt(cart)Paquete: 2 uds.24.50 €18.00 €A veces el booleano de verdad es un dato y no una elección de comportamiento: el envío exprés forma parte del pedido. Entonces al menos la llamada debería decir lo que significa:
Python tiene argumentos solo por nombre: todo lo que va después de * en la definición solo se puede pasar por su nombre. Intentarlo sin el nombre es un error:
def delivery_fee(amount, *, express=False): fee = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: fee += EXPRESS_SURCHARGE return fee
print(f"{delivery_fee(38.25, express=True):.2f} €")
try: delivery_fee(38.25, True)except TypeError as error: print(error)11.99 €delivery_fee() takes 1 positional argument but 2 were givenLa idea
Efecto secundario
Todo lo que una función cambia fuera de sí misma es un efecto secundario: un campo de un objeto, una lista que recibió, una variable global, un archivo, una base de datos, un correo, una línea en la pantalla.
Los efectos secundarios no son malos por sí mismos. El pedido hay que guardarlo en algún sitio y el correo tiene que salir. Lo malo es cuando el efecto está oculto: el nombre dice «pregunto», pero la función también actúa. Quien la llama no espera que cambie nada, y la llama dos veces sin pensarlo.
Míralo
La pregunta que cambia la respuesta
El almacén dice si hay lámparas. Alguien decidió que, si hay, es cómodo reservarlas enseguida:
class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): if self.stock >= quantity: self.stock -= quantity return True return FalseEl carrito pregunta para poder mostrar «disponible». Luego el pago pregunta lo mismo:
lamps = Warehouse(3)print("Carrito: " + ("disponible" if lamps.is_in_stock(2) else "agotado"))print("Pago: " + ("disponible" if lamps.is_in_stock(2) else "agotado"))print(f"En stock: {lamps.stock}")Carrito: disponiblePago: agotadoEn stock: 1class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): if self.stock >= quantity: self.stock -= quantity return True return False
lamps = Warehouse(3)print("Carrito: " + ("disponible" if lamps.is_in_stock(2) else "agotado"))print("Pago: " + ("disponible" if lamps.is_in_stock(2) else "agotado"))print(f"En stock: {lamps.stock}")Paso a paso, en palabras
- El almacén tiene 3 lámparas en el estante.
- El carrito solo quiere mostrar si hay 2. Pregunta is_in_stock(2): una pregunta, a juzgar por el nombre.
- 3 >= 2: hay suficientes.
- Pero la pregunta también se lleva las lámparas: self.stock pasa de 3 a 1. Nadie compró nada, y el carrito no sabe que esto ha pasado.
- El carrito recibe True y muestra «disponible».
- El cliente va al pago. El pago pregunta lo mismo: is_in_stock(2).
- 1 >= 2 es False. La misma pregunta recibe otra respuesta, y el cliente ve «agotado».
- En stock: 1. La respuesta cambió porque la primera pregunta tenía un efecto secundario oculto.
Nadie compró nada, faltan dos lámparas y el cliente no puede pagar. En una tienda real es un pedido perdido por una página que pregunta por el stock. Y el error es difícil de encontrar, porque cada línea por sí sola parece bien.
Más precisamente, para curiosos
@property: lamps.stock parece la lectura de un atributo, pero puede ser una función que cambia algo. El depurador de PyCharm evalúa las propiedades para mostrarlas, y eso puede cambiar el estado.Pruébalo
Predice la salida
El almacén con el is_in_stock que además reserva tiene ahora 5 lámparas. La misma pregunta se hace tres veces. ¿Qué imprime el programa?
lamps = Warehouse(5)print(lamps.is_in_stock(2))print(lamps.is_in_stock(2))print(lamps.is_in_stock(2))Cada «sí» se lleva 2 lámparas: de 5 pasa a 3 y luego a 1. En la tercera pregunta, 1 es menos que 2. Si la pregunta solo preguntara, las tres respuestas serían «sí».
La idea
Una orden o una consulta, no las dos
La regla se llama separación de órdenes y consultas (command–query separation): una función o responde una pregunta y no cambia nada, o hace algo y no se hace pasar por pregunta. El almacén queda así:
class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): return self.stock >= quantity
def reserve(self, quantity): if not self.is_in_stock(quantity): raise ValueError("No hay stock suficiente") self.stock -= quantitylamps = Warehouse(3)print("Carrito: " + ("disponible" if lamps.is_in_stock(2) else "agotado"))print("Pago: " + ("disponible" if lamps.is_in_stock(2) else "agotado"))lamps.reserve(2)print(f"En stock: {lamps.stock}")Carrito: disponiblePago: disponibleEn stock: 1El carrito y el pago preguntan cuanto quieran. Las lámparas se reservan en una línea que dice exactamente eso: reserve(2).
Más precisamente, para curiosos
Hay excepciones conocidas, y el nombre las delata: items.pop() quita el elemento y lo devuelve, next(iterator) avanza y devuelve. Son verbos, no preguntas, así que nadie los llama «solo para comprobar». La regla no es «una orden nunca devuelve nada» sino «una consulta nunca cambia nada».
La idea
La función pura
delivery_fee del pedido dividido responde solo desde sus parámetros y no cambia nada. Una función así se llama pura: con los mismos parámetros siempre devuelve lo mismo, sin importar cuántas veces ni en qué orden la llames.
print(f"{delivery_fee(38.25, False):.2f} €")print(f"{delivery_fee(38.25, True):.2f} €")print(f"{delivery_fee(64.00, False):.2f} €")print(f"{delivery_fee(38.25, True):.2f} €")4.99 €11.99 €0.00 €11.99 €Comprobar el envío por debajo y por encima del umbral lleva cuatro líneas. No hay almacén que llenar, ni correo que mandar, ni base de datos. Con process_order tenías que hacer un pedido para lo mismo. Por eso los cálculos se mantienen puros y los efectos secundarios se reúnen en pocas órdenes con nombres claros arriba. Cómo se convierten comprobaciones como estas en tests que protegen el código en cada cambio es el tema de la quinta lección de este módulo.
Pruébalo
Encuentra el error
El botón «Quitar el último» debe quitar el producto que el cliente añadió al final. El cliente añade leche, uvas y pan. Antes, la tienda imprime un recibo ordenado alfabéticamente:
def print_receipt(items): items.sort() for item in items: print(" " + item)cart = ["leche", "uvas", "pan"]print_receipt(cart)print("Último añadido: " + cart[-1]) leche pan uvasÚltimo añadido: uvasEl pan se añadió al final, pero el programa dice uvas. ¿En qué línea está el error?
Elige la línea con el error.
items.sort() ordena justo la lista que recibió la función, y esa lista es el carrito de quien llama. El recibo solo quería imprimir, pero cambió en silencio el orden del carrito. La solución es ordenar una copia y dejar el carrito intacto:
def print_receipt(items): for item in sorted(items): print(" " + item)sorted(items) devuelve una lista nueva, mientras que items.sort() cambia la propia lista.Ejercicio
El aviso de pedido enviado
Resuelve la tarea en tu editor.
El aviso. Esta función construye el texto, elige el canal con un flag, imprime y cuenta lo que se envió:
sent = 0
def notify_shipped(name, order_id, sms): global sent sent += 1 text = f"¡Hola, {name}! El pedido {order_id} está en camino." if sms: print("SMS: " + text) else: print("Correo: " + text)notify_shipped("María", 1042, False)notify_shipped("Lucía", 1043, True)print("Enviados: " + str(sent))Correo: ¡Hola, María! El pedido 1042 está en camino.SMS: ¡Hola, Lucía! El pedido 1043 está en camino.Enviados: 2Divídela en tres funciones: una pura que solo construye el texto, y dos órdenes, una que envía por correo y otra por SMS, cada una contando lo que envió. El flag desaparece. La salida debe quedar igual:
Correo: ¡Hola, María! El pedido 1042 está en camino.SMS: ¡Hola, Lucía! El pedido 1043 está en camino.Enviados: 2Más tareas
- Un objeto de parámetros.
place_ordertiene cuatro parámetros. Agrúpalos en un objetoordery mira cómo queda la llamada. - El cupón no vale para el envío. Marketing cambia la regla. ¿Qué función tocas y cuántas otras quedan intactas?
- La cola. Escribe
peek_next()ytake_next()para una cola de pedidos para el mensajero. ¿Cuál es una consulta y cuál es una orden?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Lanzar excepciones y crear las tuyas · Listas: append, remove y sort · Nombres en el código
- Continúa en: Comentarios que mienten y comentarios que ayudan · Code smells: método largo, clase grande, duplicación, primitivos en vez de tipos, cadenas largas de if · Las pruebas como red de seguridad · Refactorización en pasos pequeños
- Ejercicios sobre el tema: Código limpio y refactorización