Volver al Blog
Artículo12 min de lectura

La importancia de la pureza de funciones, el diseño de funciones puras y la composición

Este artículo explora cómo los conceptos de la programación funcional permiten escribir código más limpio y mantenible gracias a un comportamiento predecible y un diseño modular.

Escrito por Lambda··12 min de lectura

Introducción

La programación funcional es un paradigma de programación que trata el cómputo como la evaluación de funciones matemáticas y evita los cambios de estado y los datos mutables. Pone el énfasis en la aplicación de funciones, a diferencia del paradigma imperativo, que se centra en los cambios de estado y en la ejecución secuencial de instrucciones.

La programación funcional (PF) tiene sus raíces en las matemáticas, en particular en el cálculo lambda, y ha estado presente en el mundo académico durante décadas. En la programación funcional, las funciones son ciudadanas de primera clase: se pueden pasar como argumento y usar como cualquier otro dato. Busca funciones sin efectos secundarios, es decir, que no alteran el estado ni tienen interacciones observables con otras funciones o con el mundo exterior, salvo a través de su valor de retorno.

Esto trae varios beneficios, como un código más claro gracias a la ausencia de efectos secundarios y programas más fáciles de razonar, lo que con frecuencia hace que la PF encaje muy bien en la programación concurrente y paralela. Los conceptos funcionales también se han integrado en muchos lenguajes de uso general, como C# y Java, con características como las expresiones lambda, las funciones de orden superior y la inmutabilidad.

¿Por qué importa la pureza de las funciones?

La pureza de las funciones importa por varias razones que mejoran la confiabilidad, la mantenibilidad y la claridad del código:

  • Previsibilidad: las funciones puras siempre producen el mismo resultado ante la misma entrada, sin depender del estado del programa ni modificarlo. Eso las hace predecibles y más fáciles de razonar.
  • Sin efectos secundarios: como las funciones puras no tienen efectos secundarios, no cambian ningún estado ni provocan comportamientos inesperados en otras partes del sistema. Esto reduce la probabilidad de errores relacionados con el estado mutable compartido.
  • Concurrencia y paralelismo: las funciones puras pueden paralelizarse o ejecutarse de forma concurrente sin riesgo de condiciones de carrera, porque no comparten estado con otras funciones. Esto es cada vez más importante para el rendimiento, ya que los sistemas aprovechan cada vez más los procesadores multinúcleo.
  • Facilidad de prueba: las funciones puras son más fáciles de probar porque no requieren contexto ni preparar un estado particular. Se pueden probar de forma aislada, lo que simplifica la escritura de pruebas unitarias.
  • Caché y memoización: las funciones puras se prestan para técnicas como la memoización, en la que el resultado de una llamada se guarda en caché y se reutiliza en llamadas idénticas sin volver a calcularlo, lo que optimiza el rendimiento.
  • Refactorización: refactorizar el código es menos riesgoso, porque las funciones pueden modificarse o reemplazarse sin preocuparse por consecuencias no deseadas en partes no relacionadas del programa.
Estas ventajas contribuyen a la robustez y la simplicidad del software. Estos principios se tratan a fondo en el libro "Functional Programming in C#: How to write better C# code", de Enrico Buonanno.

Diseño de funciones puras y tipos en las firmas (en C#)

Diseñar firmas y tipos de funciones en el contexto de la programación funcional implica crear declaraciones claras y expresivas que comuniquen su intención, la naturaleza de los datos con los que trabajan y el tipo de cálculo que realizan. Este es un breve resumen de cómo diseñar firmas y tipos de funciones de forma efectiva:

  • Firmas expresivas: lo ideal es que las firmas de las funciones se describan por sí mismas y den la mayor cantidad de pistas posible sobre lo que hace la función. Por ejemplo, una firma como (IEnumerable, (T → bool)) → IEnumerable sugiere que la función opera sobre una secuencia de elementos de tipo genérico T y la filtra según un predicado, con lo que produce otra secuencia del mismo tipo. Es informativa porque insinúa que se trata de una operación de filtrado, como Enumerable.Where en C#.
  • Notación de flechas: en programación funcional, la notación de flechas se usa con frecuencia para describir tipos de funciones de forma concisa. La notación (T1, T2) → TResult representa una función que recibe dos parámetros de tipos T1 y T2 y devuelve un valor de tipo TResult. Se considera más legible, sobre todo en el caso de las funciones de orden superior.
  • Funciones de orden superior: las funciones que reciben otras funciones como parámetros o que devuelven funciones como resultado se llaman funciones de orden superior. Por ejemplo, una firma como (string, (IDbConnection → R)) → R representa una función que recibe un string y una función que opera sobre una IDbConnection y devuelve un tipo R. La función de orden superior también devuelve R, algo típico en escenarios donde se quiere abstraer un comportamiento, como las conexiones a bases de datos o las operaciones transaccionales.
  • Seguridad de tipos: elegir tipos adecuados para las entradas de una función ayuda a evitar que la función llegue siquiera a considerar entradas inválidas. Por ejemplo, en lugar de usar tipos genéricos como int para dominios específicos, puedes crear tipos personalizados que encapsulen las reglas de validación y que solo puedan representar datos válidos. Así, los valores incorrectos se detectan en tiempo de compilación y no en tiempo de ejecución.

Patrones en la programación funcional

Los patrones de la programación funcional son soluciones comunes a problemas recurrentes que respetan los principios del paradigma. Suelen apoyarse en funciones puras, datos inmutables y abstracciones de alto nivel para escribir código más predecible y fácil de razonar.

  • Funciones puras: funciones que no cambian ni dependen de ningún estado fuera de su alcance y que no producen efectos secundarios. Ante cualquier entrada, siempre devuelven el mismo resultado. Ejemplo: int Add(int x, int y) => x + y;
  • Recursión: funciones que se llaman a sí mismas para resolver un problema dividiéndolo en subproblemas más pequeños. Ejemplo: calcular de forma eficiente los números de Fibonacci con una función recursiva.
  • Tipos de datos inmutables: estructuras de datos que, una vez creadas, no pueden cambiarse. Toda operación que aparentemente modifica los datos devuelve en realidad una nueva instancia. Ejemplo: en muchos lenguajes, la manipulación de strings siempre devuelve un nuevo string en lugar de cambiar el original.
  • Mónadas: tipos que encapsulan un valor y un patrón de cálculo, y que ofrecen una forma de encadenar cálculos. Entre las mónadas están los tipos Option, Result y List, que ofrecen operaciones como bind (también conocida como flatMap) y return (para envolver valores en la mónada). Ejemplo: IEnumerable en C# es una mónada que permite encadenar operaciones con expresiones LINQ.
  • Coincidencia de patrones (pattern matching): una forma de desestructurar e inspeccionar datos, especialmente útil con tipos de datos algebraicos como las uniones o los tipos suma. Reemplaza condicionales complejos y es más declarativa. Ejemplo: usar expresiones switch en C# 8.0 o superior para evaluar distintas formas posibles de los datos.
  • Composición de funciones: crear nuevas funciones combinando dos o más funciones, de modo que la salida de una sea la entrada de otra. Ejemplo: .Select(...).Where(...) en LINQ, en C#, compone funciones para primero transformar y luego filtrar una colección.
  • Funciones de orden superior: funciones que reciben otras funciones como parámetros o las devuelven como resultado, o ambas. Ejemplo: los métodos Select y Where de LINQ son funciones de orden superior, ya que reciben expresiones lambda como argumentos.
  • Currificación (currying): transformar una función que recibe varios argumentos en una serie de funciones que reciben un solo argumento cada una. Ejemplo: descomponer una función que calcula el área de un rectángulo en una secuencia de funciones que requieren un parámetro cada una.
const add = (x, y) => x + y;
add(10, 2); // 12

const curriedAdd = (x) => { return (y) => { return x + y; }; };

curriedAdd(10)(2); // 12

  • Evaluación perezosa (lazy evaluation): una estrategia de evaluación que retrasa el cálculo de un valor hasta que realmente se necesita. Ejemplo: la ejecución de IEnumerable en las consultas LINQ es perezosa; la consulta solo se ejecuta cuando se recorre.
  • Memoización: guardar en caché los resultados de una función para evitar cálculos repetidos con las mismas entradas. Suele implementarse con funciones de orden superior que reciben una función y devuelven una versión memoizada. Ejemplo: en C#, una función de memoización podría guardar en un Dictionary los resultados de cálculos costosos y devolver el resultado en caché en las siguientes llamadas con los mismos argumentos.
const memoize = (fn) => {
  const cache = {};
  return (...args) => {
    const stringifiedArgs = JSON.stringify(args);
    const result = cache[stringifiedArgs] = cache[stringifiedArgs] || fn(...args);
    return result;
  };
};
  • Tipo Option/Maybe: un patrón para manejar la ausencia de un valor sin recurrir a referencias nulas, que con frecuencia provocan NullPointerException. Ejemplo: C# no tiene un tipo Option integrado, pero puedes crear un tipo Option personalizado que contenga un valor de tipo T o represente la ausencia de valor.
``C# // Usando bibliotecas funcionales de C# Option Divide(int x, int y) => y == 0 ? None : Some(x / y);

  • Tipos de datos algebraicos: tipos compuestos por otros tipos, que suelen usarse para crear jerarquías de tipos complejas sobre las que se aplica coincidencia de patrones. Ejemplo: en otros lenguajes funcionales, como F#, existen las uniones discriminadas, que funcionan como tipos de datos algebraicos y permiten un modelado expresivo del dominio, algo que C# puede imitar con herencia y coincidencia de patrones.
Estos patrones son fundamentales en la programación funcional y guían el desarrollo de código robusto, conciso y mantenible. Pueden mejorar drásticamente la forma en que los desarrolladores abordan los problemas y la arquitectura de los sistemas, con beneficios como una concurrencia más sencilla, mejor capacidad de prueba y código más claro. (Buonanno, E. (s. f.). Functional Programming in C#: How to write better C# code.)

Diseñar programas con composición de funciones

Empecemos repasando la composición de funciones y su relación con el encadenamiento de métodos. Para cualquier programador, la composición de funciones es casi algo natural: se aprende en el camino, tal vez en la universidad, y luego se usa sin pensarlo mucho. Así que repasemos rápidamente la definición para entenderla bien.

Composición de funciones

La composición de funciones consiste en combinar varias funciones para crear una nueva. Dadas dos funciones, f y g, su composición, representada como h = f · g, permite aplicar g a un valor y luego aplicar f al resultado. Esto resulta útil para crear flujos de trabajo complejos dividiéndolos en funciones más pequeñas y reutilizables.

Por ejemplo, en un flujo de transferencia de dinero, puedes definir funciones para validar la transferencia y para registrarla. Estas funciones pueden componerse para crear una función de más alto nivel que represente todo el flujo.

Encadenamiento de métodos

El encadenamiento de métodos es una sintaxis que permite encadenar varias invocaciones de métodos con el operador punto. En lenguajes como C#, ofrece una forma más legible de lograr la composición de funciones. Al encadenar métodos, puedes crear una secuencia de operaciones que represente un flujo de trabajo.

Por ejemplo, supongamos que quieres obtener la dirección de correo de alguien que trabaja en Manning. Puedes tener una función que calcule la parte local (la que identifica a la persona) y otra que agregue el dominio:

C# Func emailFor = p => AppendDomain(AbbreviateName(p));

var opt = Some(new Person("John", "Doe"));

var a = opt.Map(emailFor); var b = opt.Map(AbbreviateName) .Map(AppendDomain); `

En el ejemplo de cómo obtener la dirección de correo de una persona, puedes encadenar métodos como AbbreviateName() y AppendDomain()` para obtener el resultado deseado. Esto mejora la legibilidad, sobre todo a medida que aumenta la complejidad del flujo.

Definir flujos de trabajo con composición de funciones y encadenamiento de métodos permite escribir código modular y reutilizable, ya que cada función puede componerse y reutilizarse en distintos flujos. También mejora la legibilidad, porque la secuencia de operaciones se expresa de forma clara y lineal.

Con estas técnicas puedes diseñar programas expresivos, eficientes y fáciles de entender. Ya sea que trabajes con LINQ en C# o con otros paradigmas funcionales, la composición de funciones y el encadenamiento de métodos son herramientas valiosas para diseñar flujos de trabajo.

Pensar en términos de flujo de datos

La composición de funciones permite construir programas completos encadenando funciones. En este enfoque, cada función procesa su entrada y el resultado se convierte en la entrada de la siguiente función de la cadena. Esta forma de estructurar un programa invita a verlo desde la perspectiva del flujo de datos. En esencia, tu programa se convierte en una secuencia de funciones por la que los datos avanzan con fluidez, de una función a la siguiente.

Un flujo lineal es el tipo más sencillo: cada función recibe una entrada y pasa su salida a la siguiente. La salida de una función se convierte en la entrada de la siguiente, y el programa se vuelve una secuencia de transformaciones sobre los datos.

Conclusión

La programación funcional nos ofrece otra forma de pensar la programación, que aporta soluciones distintas a los problemas más comunes del paradigma orientado a objetos. Es un paradigma declarativo que se enfoca en qué resolver más que en cómo resolverlo. Conocer distintos paradigmas te ayudará a ser un mejor programador e incluso a imitar en tu código patrones de otros paradigmas para resolver problemas específicos. Los paradigmas de programación no son mutuamente excluyentes y suelen evolucionar junto con las capacidades de los lenguajes, lo que lleva a la industria del software hacia formas más flexibles y poderosas de resolver problemas.

Referencias

  • Functional Programming in C#. (s. f.). Manning Publications. https://www.manning.com/books/functional-programming-in-c-sharp
  • Widman, J. (2022). Learning Functional Programming. (s. l.): O'Reilly Media.
L

Escrito por

Lambda

Construimos productos digitales que marcan la diferencia. Combinamos diseño, ingeniería y estrategia para ayudar a las empresas a crecer.

Trabajar con nosotros