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 decimal FreeDeliveryFrom = 50m;
const decimal StandardDeliveryFee = 4.99m;
const decimal ExpressSurcharge = 7m;
const string SpringCoupon = "SPRING10";
const decimal SpringDiscount = 0.10m;
static List<decimal> orders = new List<decimal>();

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

static void ProcessOrder(string email, decimal[] prices, string coupon, bool express)
{
decimal amount = 0;
foreach (decimal price in prices)
{
amount += price;
}
if (coupon == SpringCoupon)
{
amount = amount * (1 - SpringDiscount);
}
decimal delivery = amount >= FreeDeliveryFrom ? 0 : StandardDeliveryFee;
if (express)
{
delivery += ExpressSurcharge;
}
decimal total = amount + delivery;
orders.Add(total);
Console.WriteLine($"Para {email}: pedido confirmado, {total:F2} €");
Console.WriteLine($"[log] pedido n.º {orders.Count} por {total:F2} €");
}
ProcessOrder("maria@example.com", new decimal[] { 24.50m, 18.00m }, "SPRING10", true);
ProcessOrder("ivan@example.com", new decimal[] { 64.00m }, "", 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:

static void PlaceOrder(string email, decimal[] prices, string coupon, bool express)
{
decimal total = OrderTotal(prices, coupon, express);
orders.Add(total);
SendConfirmation(email, total);
LogOrder(orders.Count, total);
}
static void SendConfirmation(string email, decimal total)
{
Console.WriteLine($"Para {email}: pedido confirmado, {total:F2} €");
}
static void LogOrder(int number, decimal total)
{
Console.WriteLine($"[log] pedido n.º {number} por {total:F2} €");
}

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

static decimal Subtotal(decimal[] prices)
{
decimal amount = 0;
foreach (decimal price in prices)
{
amount += price;
}
return amount;
}
static decimal ApplyCoupon(decimal amount, string coupon)
{
if (coupon == SpringCoupon)
{
return amount * (1 - SpringDiscount);
}
return amount;
}
static decimal DeliveryFee(decimal amount, bool express)
{
decimal fee = amount >= FreeDeliveryFrom ? 0 : StandardDeliveryFee;
if (express)
{
fee += ExpressSurcharge;
}
return fee;
}
static decimal OrderTotal(decimal[] prices, string coupon, bool express)
{
decimal amount = Subtotal(prices);
decimal discounted = ApplyCoupon(amount, coupon);
return discounted + DeliveryFee(discounted, express);
}
PlaceOrder("maria@example.com", new decimal[] { 24.50m, 18.00m }, "SPRING10", true);
PlaceOrder("ivan@example.com", new decimal[] { 64.00m }, "", 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. decimal amount = Subtotal(prices);
  2. decimal discounted = ApplyCoupon(amount, coupon);
  3. return discounted + DeliveryFee(discounted, express);
  4. static decimal OrderTotal(decimal[] prices, string coupon, bool 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.

static void PrintSlip(decimal[] prices, bool forCourier)
{
if (forCourier)
{
Console.WriteLine($"Paquete: {prices.Length} uds.");
}
else
{
foreach (decimal price in prices)
{
Console.WriteLine($"{price:F2} €");
}
}
}
decimal[] cart = new decimal[] { 24.50m, 18.00m };
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:

static void PrintCourierSlip(decimal[] prices)
{
Console.WriteLine($"Paquete: {prices.Length} uds.");
}
static void PrintReceipt(decimal[] prices)
{
foreach (decimal price in prices)
{
Console.WriteLine($"{price:F2} €");
}
}
decimal[] cart = new decimal[] { 24.50m, 18.00m };
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:

C# tiene argumentos con nombre: en la llamada escribes el nombre del parámetro antes del valor.

Console.WriteLine($"{DeliveryFee(38.25m, express: true):F2} €");
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
{
public int Stock { get; private set; }
public Warehouse(int stock)
{
Stock = stock;
}
public bool IsInStock(int quantity)
{
if (Stock >= quantity)
{
Stock -= quantity;
return true;
}
return false;
}
}

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

var lamps = new Warehouse(3);
Console.WriteLine("Carrito: " + (lamps.IsInStock(2) ? "disponible" : "agotado"));
Console.WriteLine("Pago: " + (lamps.IsInStock(2) ? "disponible" : "agotado"));
Console.WriteLine("En stock: " + lamps.Stock);
Salida
Carrito: disponible
Pago: agotado
En stock: 1

class Warehouse
{
public int Stock { get; private set; }
public Warehouse(int stock)
{
Stock = stock;
}
public bool IsInStock(int quantity)
{
if (Stock >= quantity)
{
Stock -= quantity;
return true;
}
return false;
}
}
var lamps = new Warehouse(3);
Console.WriteLine("Carrito: " + (lamps.IsInStock(2) ? "disponible" : "agotado"));
Console.WriteLine("Pago: " + (lamps.IsInStock(2) ? "disponible" : "agotado"));
Console.WriteLine("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: 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 una propiedad: lamps.Stock parece la lectura de un campo. Visual Studio evalúa las propiedades para mostrarlas en el depurador, así que una propiedad con efecto secundario cambia el estado solo porque te paraste en un punto de interrupción y miraste el objeto.

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?

var lamps = new Warehouse(5);
Console.WriteLine(lamps.IsInStock(2));
Console.WriteLine(lamps.IsInStock(2));
Console.WriteLine(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
{
public int Stock { get; private set; }
public Warehouse(int stock)
{
Stock = stock;
}
public bool IsInStock(int quantity)
{
return Stock >= quantity;
}
public void Reserve(int quantity)
{
if (!IsInStock(quantity))
{
throw new InvalidOperationException("No hay stock suficiente");
}
Stock -= quantity;
}
}
var lamps = new Warehouse(3);
Console.WriteLine("Carrito: " + (lamps.IsInStock(2) ? "disponible" : "agotado"));
Console.WriteLine("Pago: " + (lamps.IsInStock(2) ? "disponible" : "agotado"));
lamps.Reserve(2);
Console.WriteLine("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: stack.Pop() quita el elemento y lo devuelve, queue.Dequeue() 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.WriteLine($"{DeliveryFee(38.25m, false):F2} €");
Console.WriteLine($"{DeliveryFee(38.25m, true):F2} €");
Console.WriteLine($"{DeliveryFee(64.00m, false):F2} €");
Console.WriteLine($"{DeliveryFee(38.25m, true):F2} €");
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:

static void PrintReceipt(List<string> items)
{
items.Sort();
foreach (string item in items)
{
Console.WriteLine(" " + item);
}
}
var cart = new List<string> { "leche", "uvas", "pan" };
PrintReceipt(cart);
Console.WriteLine("Último añadido: " + cart[cart.Count - 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ó:

static int sent = 0;
static void NotifyShipped(string name, int orderId, bool sms)
{
sent++;
string text = $"¡Hola, {name}! El pedido {orderId} está en camino.";
if (sms)
{
Console.WriteLine("SMS: " + text);
}
else
{
Console.WriteLine("Correo: " + text);
}
}
NotifyShipped("María", 1042, false);
NotifyShipped("Lucía", 1043, true);
Console.WriteLine("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 una clase 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 ·