Ir al contenido
wannacode

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:

const FREE_DELIVERY_FROM = 50;
const STANDARD_DELIVERY_FEE = 4.99;
const EXPRESS_SURCHARGE = 7;
const SPRING_COUPON = "SPRING10";
const SPRING_DISCOUNT = 0.1;
const orders = [];

Y esta es la función por la que pasa cada pedido:

function processOrder(email, prices, coupon, express) {
let amount = 0;
for (const price of prices) {
amount += price;
}
if (coupon === SPRING_COUPON) {
amount = amount * (1 - SPRING_DISCOUNT);
}
let delivery = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE;
if (express) {
delivery += EXPRESS_SURCHARGE;
}
const total = amount + delivery;
orders.push(total);
console.log(`Para ${email}: pedido confirmado, ${total.toFixed(2)} €`);
console.log(`[log] pedido n.º ${orders.length} por ${total.toFixed(2)} €`);
}
processOrder("maria@example.com", [24.5, 18], "SPRING10", true);
processOrder("ivan@example.com", [64], "", false);
Salida
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 processOrder: «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:

function placeOrder(email, prices, coupon, express) {
const total = orderTotal(prices, coupon, express);
orders.push(total);
sendConfirmation(email, total);
logOrder(orders.length, total);
}
function sendConfirmation(email, total) {
console.log(`Para ${email}: pedido confirmado, ${total.toFixed(2)} €`);
}
function logOrder(number, total) {
console.log(`[log] pedido n.º ${number} por ${total.toFixed(2)} €`);
}

Debajo viene el cálculo, cada tarea en su propia función:

function subtotal(prices) {
let amount = 0;
for (const price of prices) {
amount += price;
}
return amount;
}
function applyCoupon(amount, coupon) {
if (coupon === SPRING_COUPON) {
return amount * (1 - SPRING_DISCOUNT);
}
return amount;
}
function deliveryFee(amount, express) {
let fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE;
if (express) {
fee += EXPRESS_SURCHARGE;
}
return fee;
}
function orderTotal(prices, coupon, express) {
const amount = subtotal(prices);
const discounted = applyCoupon(amount, coupon);
return discounted + deliveryFee(discounted, express);
}
placeOrder("maria@example.com", [24.5, 18], "SPRING10", true);
placeOrder("ivan@example.com", [64], "", false);
Salida
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 orderTotal 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.

  1. const amount = subtotal(prices);
  2. function orderTotal(prices, coupon, express) {
  3. }
  4. const discounted = applyCoupon(amount, coupon);
  5. return discounted + deliveryFee(discounted, 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.

function printSlip(prices, forCourier) {
if (forCourier) {
console.log(`Paquete: ${prices.length} uds.`);
} else {
for (const price of prices) {
console.log(`${price.toFixed(2)} €`);
}
}
}
const cart = [24.5, 18];
printSlip(cart, true);
printSlip(cart, false);
Salida
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:

function printCourierSlip(prices) {
console.log(`Paquete: ${prices.length} uds.`);
}
function printReceipt(prices) {
for (const price of prices) {
console.log(`${price.toFixed(2)} €`);
}
}
const cart = [24.5, 18];
printCourierSlip(cart);
printReceipt(cart);
Salida
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:

JavaScript no tiene argumentos con nombre, así que pasas un objeto de opciones, y la función lo desempaqueta en sus parámetros:

function deliveryFee(amount, { express = false } = {}) {
let fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE;
if (express) {
fee += EXPRESS_SURCHARGE;
}
return fee;
}
console.log(`${deliveryFee(38.25, { express: true }).toFixed(2)} €`);
Salida
11.99 €

La 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 {
constructor(stock) {
this.stock = stock;
}
isInStock(quantity) {
if (this.stock >= quantity) {
this.stock -= quantity;
return true;
}
return false;
}
}

El carrito pregunta para poder mostrar «disponible». Luego el pago pregunta lo mismo:

const lamps = new Warehouse(3);
console.log("Carrito: " + (lamps.isInStock(2) ? "disponible" : "agotado"));
console.log("Pago: " + (lamps.isInStock(2) ? "disponible" : "agotado"));
console.log("En stock: " + lamps.stock);
Salida
Carrito: disponible
Pago: agotado
En stock: 1

class Warehouse {
constructor(stock) {
this.stock = stock;
}
isInStock(quantity) {
if (this.stock >= quantity) {
this.stock -= quantity;
return true;
}
return false;
}
}
const lamps = new Warehouse(3);
console.log("Carrito: " + (lamps.isInStock(2) ? "disponible" : "agotado"));
console.log("Pago: " + (lamps.isInStock(2) ? "disponible" : "agotado"));
console.log("En stock: " + lamps.stock);
Paso a paso, en palabras
  1. El almacén tiene 3 lámparas en el estante.
  2. El carrito solo quiere mostrar si hay 2. Pregunta isInStock(2): una pregunta, a juzgar por el nombre.
  3. 3 >= 2: hay suficientes.
  4. Pero la pregunta también se lleva las lámparas: this.stock pasa de 3 a 1. Nadie compró nada, y el carrito no sabe que esto ha pasado.
  5. El carrito recibe true y muestra «disponible».
  6. El cliente va al pago. El pago pregunta lo mismo: isInStock(2).
  7. 1 >= 2 es false. La misma pregunta recibe otra respuesta, y el cliente ve «agotado».
  8. 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
Es aún más traicionero en un getter (get stock()): lamps.stock parece la lectura de un campo, pero es una función. La consola del navegador y el depurador evalúan los getters cuando los expandes, y eso puede cambiar el estado.

Pruébalo

Predice la salida

El almacén con el isInStock que además reserva tiene ahora 5 lámparas. La misma pregunta se hace tres veces. ¿Qué imprime el programa?

const lamps = new Warehouse(5);
console.log(lamps.isInStock(2));
console.log(lamps.isInStock(2));
console.log(lamps.isInStock(2));
Respuesta

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 {
constructor(stock) {
this.stock = stock;
}
isInStock(quantity) {
return this.stock >= quantity;
}
reserve(quantity) {
if (!this.isInStock(quantity)) {
throw new Error("No hay stock suficiente");
}
this.stock -= quantity;
}
}
const lamps = new Warehouse(3);
console.log("Carrito: " + (lamps.isInStock(2) ? "disponible" : "agotado"));
console.log("Pago: " + (lamps.isInStock(2) ? "disponible" : "agotado"));
lamps.reserve(2);
console.log("En stock: " + lamps.stock);
Salida
Carrito: disponible
Pago: disponible
En stock: 1

El 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, iterator.next() 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

deliveryFee 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.

console.log(`${deliveryFee(38.25, false).toFixed(2)} €`);
console.log(`${deliveryFee(38.25, true).toFixed(2)} €`);
console.log(`${deliveryFee(64, false).toFixed(2)} €`);
console.log(`${deliveryFee(38.25, true).toFixed(2)} €`);
Salida
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 processOrder 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:

function printReceipt(items) {
items.sort();
for (const item of items) {
console.log(" " + item);
}
}
const cart = ["leche", "uvas", "pan"];
printReceipt(cart);
console.log("Último añadido: " + cart[cart.length - 1]);
Salida
leche
pan
uvas
Último añadido: uvas

El 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.

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ó:

let sent = 0;
function notifyShipped(name, orderId, sms) {
sent++;
const text = `¡Hola, ${name}! El pedido ${orderId} está en camino.`;
if (sms) {
console.log("SMS: " + text);
} else {
console.log("Correo: " + text);
}
}
notifyShipped("María", 1042, false);
notifyShipped("Lucía", 1043, true);
console.log("Enviados: " + sent);
Salida
Correo: ¡Hola, María! El pedido 1042 está en camino.
SMS: ¡Hola, Lucía! El pedido 1043 está en camino.
Enviados: 2

Diví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:

Salida
Correo: ¡Hola, María! El pedido 1042 está en camino.
SMS: ¡Hola, Lucía! El pedido 1043 está en camino.
Enviados: 2

Más tareas

  • Un objeto de parámetros. placeOrder tiene cuatro parámetros. Agrúpalos en un objeto order y 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 peekNext() y takeNext() para una cola de pedidos para el mensajero. ¿Cuál es una consulta y cuál es una orden?

Test

Ponte a prueba

1. ¿Cómo sabes que processOrder hace más de una tarea?
2. printSlip(cart, true). ¿Qué está mal?
3. isInStock(2) baja el stock si hay suficiente. El carrito pregunta una vez, el pago vuelve a preguntar. ¿Qué pasa con 3 lámparas?
4. ¿Cuál de estas funciones es pura?
5. ¿Qué dice la separación de órdenes y consultas (command–query separation)?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·