Funciones Efectivas: El Corazón del Código Limpio

Funciones Efectivas: El Corazón del Código Limpio

Principios para Escribir Funciones Claras y Mantenibles

Vamos a sumergirnos en un tema que, aunque pueda parecer básico, es fundamental para cualquier desarrollador de software: las funciones. Sí, esas pequeñas (o no tan pequeñas) porciones de código que realizan tareas específicas en nuestros programas. Pero, ¿alguna vez se han preguntado qué hace que una función sea realmente buena? Bueno, eso es precisamente de lo que vamos a hablar hoy.

La importancia de las funciones bien escritas no puede subestimarse

Son la esencia de nuestro código, y su calidad afecta directamente la claridad, eficiencia y mantenibilidad de nuestro software. Imaginen funciones como los ladrillos de una casa; si están bien hechos y colocados con cuidado, la casa será sólida y duradera. De lo contrario, podríamos enfrentarnos a una estructura débil y problemática.

Ahora, hablemos de algunos principios clave que Uncle Bob nos enseña en su libro "Clean Code" y cómo podemos aplicarlos a nuestras funciones:

  1. Mantener las funciones cortas y enfocadas: Una función debería hacer una cosa y hacerla bien. Si te encuentras escribiendo una función que parece querer hacer demasiado, probablemente necesite ser dividida en funciones más pequeñas y específicas.

     // ❌ Incorrecto
     void AddToppingAndCalculatePrice(Pizza pizza, string topping, List<string> priceList) 
     {
         pizza.Toppings.Add(topping);
         pizza.Price += priceList.Find(p => p.Topping == topping).Price;
         // Más código para actualizar inventario, etc.
     }
    
      // ✔️ Correcto
     void AddTopping(Pizza pizza, string topping) 
     {
         pizza.Toppings.Add(topping);
     }
    

    En el primer bloque de código, además de añadir el ingrediente, calcula el precio y potencialmente realiza otras tareas. Esto hace que la función sea más difícil de entender y mantener. En cambio en el segundo bloque de código la función hace una sola cosa: añade un ingrediente a la pizza.

  2. Nombres descriptivos: El nombre de una función debe indicar qué hace. Si tienes dificultades para nombrar una función, podría ser una señal de que no tiene un propósito claro.

     // ❌ Incorrecto
     void PrepareFood(Pizza pizza) 
     {
         // Lógica para hornear la pizza
     }
    
     // ✔️ Correcto
     void BakePizza(Pizza pizza) 
     {
         // Lógica para hornear la pizza
     }
    

    'BakePizza' describe claramente lo que hace la función: hornear una pizza. 'PrepareFood', por otro lado, es vago y no indica qué tipo de preparación de alimentos se está realizando.

  3. Evitar argumentos excesivos: Cuantos menos argumentos tenga una función, mejor. Demasiados argumentos pueden hacer que la función sea difícil de entender y usar.

     // ❌ Incorrecto
     void UpdatePizza(Pizza pizza, PizzaSize newSize, string newCrust, int newTemperature, bool isGlutenFree) 
     {
         // Actualizar múltiples propiedades de la pizza
     }
    
     // ✔️ Correcto
     void UpdatePizzaSize(Pizza pizza, PizzaSize newSize) 
     {
         pizza.Size = newSize;
     }
    

    En el primer bloque de código, la función intenta hacer demasiado, actualizando múltiples aspectos de la pizza, lo que la hace menos clara y más difícil de usar. En cambio en el segundo bloque de código, la función tiene un propósito claro: actualizar el tamaño de la pizza.

  4. Minimizar efectos secundarios: Una función ideal no debería tener efectos secundarios inesperados. Debería realizar su tarea y no modificar el estado de otros elementos del programa sin una buena razón.

     // ❌ Incorrecto
     void CalculateAndUpdateTotalPrice(Pizza pizza) 
     {
         pizza.TotalPrice = pizza.BasePrice;
         foreach (var topping in pizza.Toppings) {
             pizza.TotalPrice += ToppingPrices[topping];
         }
         // Actualizar la propiedad TotalPrice de la pizza
     }
    
     // ✔️ Correcto
     decimal CalculateTotalPrice(Pizza pizza) 
     {
         decimal totalPrice = pizza.BasePrice;
         foreach (var topping in pizza.Toppings) {
             totalPrice += ToppingPrices[topping];
         }
         return totalPrice;
     }
    

    En el primer bloque de código, además de calcular el precio, actualiza la propiedad de la pizza, lo que es un efecto secundario que puede llevar a errores si no se maneja correctamente. En cambio en el segundo bloque de código, solo calcula el precio total y lo devuelve, sin modificar el estado de la pizza.

  5. Pruebas unitarias: Cada función debe ser probada. Las pruebas unitarias no solo ayudan a garantizar que tu función hace lo que debe hacer, sino que también te obligan a escribir funciones más limpias y desacopladas.

     // ❌ Incorrecto
     [Test]
     public void AddTopping_ShouldDoMultipleThings() 
     {
         var pizza = new Pizza();
         AddToppingAndCalculatePrice(pizza, "Pepperoni", priceList);
         // Intentar probar múltiples comportamientos a la vez
     }
    
     // ✔️ Correcto
     [Test]
     public void AddTopping_ShouldAddToppingToPizza() 
     {
         var pizza = new Pizza();
         AddTopping(pizza, "Pepperoni");
         Assert.Contains("Pepperoni", pizza.Toppings);
     }
    

    En el primer bloque de código, la prueba es menos clara y trata de probar múltiples comportamientos a la vez, lo que puede llevar a pruebas menos confiables y más difíciles de mantener. En cambio en el segundo bloque de código, la prueba unitaria es clara y se enfoca en un solo comportamiento: añadir un ingrediente.

Ejemplo práctico

En este ejemplo, vamos a explorar un escenario común en una aplicación para gestionar pedidos en una pizzería. El código estará dividido en varias partes clave, cada una enfocada en un aspecto específico del proceso de pedido de pizzas.

Escenario General:

Imaginemos una pequeña pizzería que ofrece un servicio de pedidos en línea. Los clientes pueden elegir el tamaño de la pizza, agregar ingredientes adicionales y realizar su pedido a través de la aplicación. La aplicación debe ser capaz de gestionar los pedidos, calcular el precio total y mantener la información del cliente.

Partes del Código:

  1. Definición de Clases: Crearemos clases básicas para representar los componentes esenciales de la pizzería: pizzas, pedidos y clientes. Estas clases servirán como la base de nuestra aplicación.

  2. Creación y Manejo de Pedidos: Desarrollaremos una clase de servicio que maneje la lógica de negocio, como la creación de pedidos y la construcción de pizzas con sus respectivos ingredientes.

  3. Interacción del Usuario con el Servicio: Finalmente, veremos cómo un usuario (en este caso, simulado por el método Main) interactúa con el servicio de la pizzería para hacer un pedido, añadir pizzas y visualizar el resumen del pedido.

Cada bloque de código estará acompañado de una descripción para entender mejor su función y cómo se relaciona con los principios de Clean Code. Este enfoque modular y descriptivo nos ayudará a visualizar cómo cada parte contribuye al funcionamiento general de la aplicación de la pizzería.

Ahora, veamos cada parte en detalle.

1. Definición de Clases

Primero, definiremos algunas clases básicas para representar los elementos de nuestra pizzería: Pizza, Order, y Customer.

public enum PizzaSize { Small, Medium, Large }
public class Pizza 
{
    public PizzaSize Size { get; set; }
    public List<string> Toppings { get; private set; } = new List<string>();

    public void AddTopping(string topping) => Toppings.Add(topping);
}
public class Customer 
{
    public string Name { get; set; }
    public string Address { get; set; }
}
public class Order 
{
    public Customer Customer { get; set; }
    public List<Pizza> Pizzas { get; private set; } = new List<Pizza>();
    public decimal TotalPrice { get; private set; }

    public void AddPizza(Pizza pizza) 
    {
        Pizzas.Add(pizza);
        CalculateTotalPrice();
    }

    private void CalculateTotalPrice() 
    {
        // Cálculo del precio total
    }
}

Aquí, cada clase tiene una responsabilidad clara. La clase Pizza se encarga de todo lo relacionado con una pizza individual, como su tamaño y los ingredientes. La clase Customer maneja la información del cliente. La clase Order gestiona un pedido, incluyendo las pizzas ordenadas y el cliente que realizó el pedido.

2. Creación y Manejo de un Pedido

Ahora, veamos cómo estas clases pueden utilizarse para crear y gestionar un pedido.

public class PizzaShopService 
{
    public Order CreateOrder(Customer customer) 
    {
        return new Order { Customer = customer };
    }

    public Pizza CreatePizza(PizzaSize size, List<string> toppings) 
    {
        var pizza = new Pizza { Size = size };
        foreach (var topping in toppings) {
            pizza.AddTopping(topping);
        }
        return pizza;
    }
}

PizzaShopService es una clase que encapsula la lógica para crear pedidos y pizzas. Proporciona métodos para crear un nuevo pedido y para construir una pizza con un tamaño y toppings específicos.

3. Uso del Servicio de Pizzería

Por último, veamos cómo un cliente podría utilizar estos servicios para hacer un pedido.

public class PizzaShopApplication 
{
    public static void Main(string[] args) 
    {
        var pizzaShopService= new PizzaShopService();
        var customer = new Customer { Name = "John Doe", Address = "Calle Falsa 123" };
        var order = pizzeriaService.CreateOrder(customer);

        var pizza = pizzaShopService.CreatePizza(PizzaSize.Medium, new List<string> { "Pepperoni", "Queso extra" });
        order.AddPizza(pizza);

        // Mostrar detalles del pedido
        Console.WriteLine($"Pedido para: {order.Customer.Name}");
        Console.WriteLine($"Dirección: {order.Customer.Address}");
        Console.WriteLine($"Total a pagar: {order.TotalPrice}");
    }
}

Aquí, creamos una instancia del servicio de pizzería, un cliente y luego un pedido para ese cliente. Creamos una pizza y la añadimos al pedido. Finalmente, mostramos los detalles del pedido. Este código demuestra cómo se pueden usar las clases y métodos definidos anteriormente de manera cohesiva y ordenada.

Al finalizar nuestro recorrido por el ejemplo práctico de la pizzería, espero que haya quedado claro cómo los principios de Clean Code se aplican en un escenario real de desarrollo de software. A través de este ejemplo en C#, hemos visto la importancia de mantener nuestro código organizado, modular y centrado en su propósito.

Lo más destacado de este ejemplo es cómo cada clase y función tiene un rol bien definido. Las clases Pizza, Customer y Order encapsulan claramente las responsabilidades y datos relevantes. El servicio PizzaShopService actúa como un intermediario entre la lógica de negocio y la interacción del usuario, manteniendo una separación limpia y ordenada entre estas capas.

Además, la manera en que se estructuró el código facilita su comprensión y mantenimiento. Por ejemplo, si en el futuro quisiéramos agregar nuevas características, como ofertas especiales o tipos de masa diferentes, podríamos hacerlo con cambios mínimos y bien localizados, gracias a la estructura clara y modular que hemos establecido.

Conclusión: El poder de una función

Para concluir, recordemos que escribir funciones limpias y eficientes no es solo una cuestión de seguir reglas; es una práctica que mejora con el tiempo y la experiencia. Cada función que escribimos es una oportunidad para mejorar nuestro oficio y contribuir a un código más saludable y sostenible. ¡Así que la próxima vez que te sientes a escribir una función, recuerda estos consejos y haz que cada línea de código cuente!

Espero que hayan encontrado útil este post. En los próximos artículos, seguiremos explorando otros aspectos del Código Limpio y cómo podemos aplicarlos en nuestro día a día como desarrolladores de software. ¡Hasta la próxima!

Happy Coding 😸