Capítulo 1. Introducción a la programación funcional

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Para comprender mejor cómo incorporar un estilo de programación más funcional en Java, primero tienes que entender qué significa que un lenguaje sea funcional y cuáles son sus conceptos fundamentales.

Este capítulo explorará las raíces de la programación funcional necesarias para incorporar un estilo de programación más funcional a tu flujo de trabajo.

¿Qué hace que una lengua sea funcional?

Los paradigmas de programación, como el orientado a objetos, el funcional o el procedimental, son conceptos generales sintéticos que clasifican los lenguajes y proporcionan formas de estructurar tus programas con un estilo específico y de utilizar distintos enfoques para resolver problemas. Como la mayoría de los paradigmas, la programación funcional no tiene una definición única consensuada, y se libran muchas guerras territoriales sobre lo que define a un lenguaje como realmente funcional. En lugar de dar mi propia definición, repasaré distintos aspectos de lo que hace que un lenguaje sea funcional.

Se considera que un lenguaje es funcional cuando existe una forma de expresar cómputos creando y combinando funciones abstractas.Este concepto tiene sus raíces en el sistema matemático formal cálculo lambda, inventado por el lógico Alonzo Church en la década de 1930.1 Es un sistema para expresar cálculos con funciones abstractas y cómo aplicarles variables. El nombre "cálculo lambda" procede de la letra griega "lambda" elegida para su símbolo: λ .

Como desarrollador orientado a objetos, estás acostumbrado a la programación imperativa: al definir una serie de sentencias, le estás diciendo al ordenador lo que debe hacer para realizar una tarea concreta con una secuencia de sentencias.

Para que un lenguaje de programación se considere funcional, tiene que poder conseguirse un estilo declarativo que exprese la lógica de los cálculos sin describir su flujo de control real.En un estilo de programación declarativo de este tipo, describes el resultado y cómo debe funcionar tu programa con expresiones, no lo que debe hacer con sentencias.

En Java, una expresión es una secuencia de operadores, operandos e invocaciones a métodos que definen un cálculo y se evalúan en un único valor:

x * x
2 * Math.PI * radius
value == null ? true : false

Las sentencias, por otra parte, son acciones que realiza tu código para formar una unidad completa de ejecución, incluidas las invocaciones a métodos sin valor de retorno. Cada vez que asignas o cambias el valor de una variable, llamas a un método void o utilizas construcciones de flujo de control como if-else, estás utilizando sentencias. Normalmente, están entremezcladas con expresiones:

int totalTreasure = 0; 1

int newTreasuresFound = findTreasure(6); 2

totalTreasure = totalTreasure + newTreasuresFound; 3

if (totalTreasure > 10) { 4
  System.out.println("You have a lot of treasure!"); 5
} else {
  System.out.println("You should look for more treasure!"); 5
}
1

Asigna un valor inicial a una variable, introduciendo estado en el programa.

2

La llamada a la función findTreasure(6) es una expresión funcional, pero la asignación de newTreasuresFound es una declaración.

3

La reasignación de totalTreasure es una declaración que utiliza el resultado de la expresión del lado derecho.

4

La expresión de flujo de control if-else transmite qué acción debe realizarse en función del resultado de la expresión (totalTreasure > 10).

5

Imprimir en System.out es una declaración porque no se devuelve ningún resultado de la llamada.

La principal distinción entre expresiones y sentencias es si se devuelve o no un valor. En un lenguaje multiparadigma de propósito general como Java, las líneas que las separan son a menudo objeto de debate y pueden difuminarse rápidamente.

Conceptos de programación funcional

Puesto que la programación funcional se basa principalmente en funciones abstractas, sus muchos conceptos que forman el paradigma pueden centrarse en "qué resolver" en un estilo declarativo, en contraste con el enfoque imperativo de "cómo resolver".

Repasaremos los aspectos más comunes y significativos en los que se basa la programación funcional. Aunque no son exclusivos del paradigma funcional, muchas de las ideas que los sustentan se aplican también a otros paradigmas de programación.

Funciones puras y transparencia referencial

La programación funcional clasifica las funciones en dos categorías: puras e impuras.

Las funciones puras tienen dos garantías elementales:

La misma entrada siempre creará la misma salida

El valor de retorno de una función pura debe depender únicamente de sus argumentos de entrada.

Son autónomos sin ningún tipo de efecto secundario

El código no puede afectar al estado global, como cambiar los valores de los argumentos o utilizar cualquier E/S.

Estas dos garantías permiten que las funciones puras sean seguras de utilizar en cualquier entorno, incluso de forma paralela. El código siguiente muestra que un método es una función pura que acepta un argumento sin afectar a nada fuera de su contexto:

public String toLowerCase(String str) {
  return str.toLowerCase();
}

Las funciones que violan cualquiera de las dos garantías se consideran impuras. El código siguiente es un ejemplo de función impura, ya que utiliza la hora actual para su lógica:

public String buildGreeting(String name) {
  var now = LocalTime.now();
  if (now.getHour() < 12) {
    return "Good morning " + name;
  } else {
    return "Hello " + name;
  }
}

Los significantes "puras" e "impuras" son nombres bastante desafortunados por la connotación que pueden invocar. Las funciones impuras no son inferiores a las funciones puras en general, sólo se utilizan de formas distintas según el estilo de codificación y el paradigma al que quieras adherirte.

Otro aspecto de las expresiones sin efectos secundarios o funciones puras es su naturaleza determinista, que las hace referencialmente transparentes, lo que significa que puedes sustituirlas por su respectivo resultado evaluado para cualquier otra invocación sin cambiar el comportamiento de tu programa.

Función:

f ( x ) = x * x

Sustitución de expresiones evaluadas:

r e s u l t = f ( 5 ) + f ( 5 ) = 25 + f ( 5 ) = 25 + 25

Todas estas variantes son iguales y no cambiarán tu programa. La pureza y la transparencia referencial van de la mano y te proporcionan una herramienta poderosa porque es más fácil entender y razonar con tu código.

Inmutabilidad

El código orientado a objetos suele basarse en un estado mutable del programa. Los objetos pueden y suelen cambiar después de su creación, mediante setters. Pero mutar estructuras de datos puede crear efectos secundarios inesperados. Pero la mutabilidad no se limita a las estructuras de datos y la POO. Una variable local en un método también puede ser mutable, y puede provocar problemas en su contexto tanto como un campo cambiante de un objeto.

Con la inmutabilidad, las estructuras de datos ya no pueden cambiar tras su inicialización. Al no cambiar nunca, son siempre coherentes, sin efectos secundarios, predecibles y más fáciles de razonar. Al igual que las funciones puras, su uso es seguro en entornos concurrentes y paralelos, sin los problemas habituales de acceso no sincronizado o cambios de estado fuera de alcance.

Si las estructuras de datos no cambiaran nunca tras la inicialización, un programa no sería muy útil. Por eso necesitas crear una versión nueva y actualizada que contenga el estado mutado, en lugar de cambiar directamente la estructura de datos.

Crear nuevas estructuras de datos para cada cambio puede ser una tarea pesada y bastante ineficaz debido a que hay que copiar los datos cada vez. Muchos lenguajes de programación emplean "estructuras compartidas" para proporcionar mecanismos de copia eficaces que minimicen la ineficacia de necesitar nuevas estructuras de datos para cada cambio. De esta forma, diferentes instancias de estructuras de datos comparten datos inmutables entre ellas. Enel Capítulo 4 se explicará con más detalle por qué las ventajas de tener estructuras de datos sin efectos secundarios compensan el trabajo extra que puede ser necesario.

Recursión

La recursividad es una técnica de resolución de problemas que resuelve un problema resolviendo parcialmente problemas de la misma forma y combinando los resultados parciales para resolver finalmente el problema original. En términos sencillos, las funciones recursivas se llaman a sí mismas, pero con un ligero cambio en sus argumentos de entrada, hasta que alcanzan una condición final y devuelven un valor real.El Capítulo 12 entrará en los detalles más sutiles de la recursividad.

Un ejemplo sencillo es calcular un factorial, el producto de todos los enteros positivos menores o iguales que el parámetro de entrada. En lugar de calcular el valor con un estado intermedio, la función se llama a sí misma con una variable de entrada decrementada, como se ilustra en la Figura 1-1.

Calculating a factorial with recursion
Figura 1-1. Calcular un factorial con recursión

La programación funcional pura a menudo prefiere utilizar la recursividad en lugar de bucles o iteradores. Algunas de ellas, como Haskell, van un paso más allá y no tienen bucles como for o while en absoluto.

Las llamadas repetidas a funciones pueden ser ineficaces e incluso peligrosas por el riesgo de que se desborde la pila. Por eso muchos lenguajes funcionales utilizan optimizaciones como la recursión "desenrollada" en bucles o la optimización de llamadas de cola para reducir los marcos de pila necesarios. Java no admite ninguna de estas técnicas de optimización, de las que hablaré más en el Capítulo 12.

Funciones de primera clase y de orden superior

Muchos de los conceptos discutidos anteriormente no tienen por qué estar disponibles como características del lenguaje profundamente integradas para soportar un estilo de programación más funcional en tu código. Los conceptos de funciones de primera clase y de orden superior, sin embargo, son absolutamente imprescindibles.

Para que las funciones sean los llamados "ciudadanos de primera clase", deben observar todas las propiedades inherentes a otras entidades del lenguaje. Tienen que poder asignarse a variables y utilizarse como argumentos y valores de retorno en otras funciones y expresiones.

Las funcionesde orden superior utilizan esta ciudadanía de primera clase para aceptar funciones como argumentos o para devolver una función como resultado, o ambas cosas. Ésta es una propiedad esencial para el siguiente concepto, la composición funcional.

Composición funcional

Las funciones puras pueden combinarse para crear expresiones más complejas. En términos matemáticos, esto significa que las dos funciones f ( x ) y g ( y ) pueden combinarse en una función h ( x ) = g ( f ( x ) ) como se ve en la Figura 1-2.

Composing function f and g to a new function h
Figura 1-2. Funciones de composición

De este modo, las funciones pueden ser lo más pequeñas y puntuales posible, y por tanto más fáciles de reutilizar. Para crear una tarea más compleja y completa, dichas funciones pueden componerse rápidamente según sea necesario.

Currying

La curaduría de funciones consiste en convertir una función que toma varios argumentos en una secuencia de funciones que sólo toman un argumento cada una.

Nota

La técnica del curry toma prestado su nombre del matemático y lógico Haskell Brooks Curry (1900-1982). No sólo es el homónimo de la técnica funcional llamada currying, sino que también tiene tres lenguajes de programación diferentes que llevan su nombre: Haskell, Brook y Curry.

Imagina una función que acepta tres argumentos. Se puede curar de la siguiente manera:

Función inicial:

x = f ( a , b , c )

Funciones al curry:

h = g ( a ) i = h ( b ) x = i ( c )

Secuencia de funciones currificadas:

x = g ( a ) ( b ) ( c )

Algunos lenguajes de programación funcional reflejan el concepto general de currying en sus definiciones de tipos, como Haskell, de la siguiente manera:

add :: Integer -> Integer -> Integer 1
add x y =  x + y 2
1

La función add se declara para aceptar un Integer y devuelve otra función que acepta otro Integer, que a su vez devuelve un Integer.

2

La definición real refleja la declaración: dos parámetros de entrada y el resultado del cuerpo como valor de retorno.

A primera vista, el concepto puede parecer extraño y ajeno a un desarrollador OO o imperativo, como muchos principios basados en las matemáticas. Aun así, transmite perfectamente cómo una función con más de un argumento es representable como una función de funciones, y eso es una comprensión esencial para apoyar el siguiente concepto.

Aplicación de funciones parciales

La aplicaciónparcial defunciones es el proceso de crear una nueva función proporcionando sólo un subconjunto de los argumentos necesarios a una función existente. A menudo se confunde con el currying, pero una llamada a una función aplicada parcialmente devuelve un resultado y no otra función de una cadena de currying.

El ejemplo del curry de la sección anterior puede aplicarse parcialmente para crear una función más específica:

add :: Integer -> Integer -> Integer 1
add x y =  x + y

add3 = add 3 2

add3 5 3
1

La función add se declara como antes, aceptando dos argumentos.

2

La llamada a la función add con sólo un valor para el primer argumento x devuelve como función parcialmente aplicada el tipo Integer → Integer, que está ligado al nombre add3.

3

La llamada add3 5 es equivalente a add 3 5.

Con la aplicación parcial, puedes crear nuevas funciones menos verbosas sobre la marcha o funciones especializadas a partir de un conjunto más genérico para adaptarlas al contexto y los requisitos actuales de tu código.

Evaluación perezosa

La evaluación perezosa es una estrategia de evaluación que retrasa la evaluación de una expresión hasta que su resultado es literalmente necesario, separando las preocupaciones de cómo creas una expresión de si realmente la utilizas o cuándo lo haces. También es otro concepto no arraigado ni restringido a la programación funcional, pero es imprescindible para utilizar otros conceptos y técnicas funcionales.

Muchos lenguajes no funcionales, incluido Java, se evalúanprincipalmente de forma estricta -o ansiosa-, lo que significa que una expresión se evalúa inmediatamente. Esos lenguajes siguen teniendo algunas construcciones perezosas, como las sentencias de flujo de control, como las sentencias if-else o los bucles, o los operadores de cortocircuito lógico. Evaluar inmediatamente las dos ramas de una construcción if-else o todas las iteraciones posibles de un bucle no tendría mucho sentido, ¿verdad? Así que, en su lugar, sólo se evalúan las ramas y las iteraciones absolutamente necesarias durante el tiempo de ejecución.

La pereza permite ciertas construcciones que no son posibles de otro modo, como estructuras de datos infinitas o implementaciones más eficientes de algunos algoritmos.También funciona muy bien con la transparencia referencial. Si no hay diferencia entre una expresión y su resultado, puedes retrasar la evaluación sin consecuencias para el resultado. La evaluación retrasada puede seguir afectando al rendimiento del programa, porque puede que no conozcas el momento exacto de la evaluación.

En el Capítulo 11 hablaré de cómo conseguir un enfoque perezoso en Java con las herramientas que tienes a tu disposición, y de cómo crear las tuyas propias.

Ventajas de la programación funcional

Tras repasar los conceptos más comunes y esenciales de la programación funcional, podrás ver cómo se reflejan en las ventajas que proporciona un enfoque más funcional:

Simplicidad

Sin estado mutable ni efectos secundarios, tus funciones tienden a ser más pequeñas, haciendo "sólo lo que se supone que deben hacer".

Coherencia

Las estructuras de datos inmutables son fiables y coherentes. Se acabaron las preocupaciones por el estado inesperado o no intencionado del programa.

Corrección (matemática)

Un código más sencillo, con estructuras de datos coherentes, conducirá automáticamente a un código "más correcto", con una superficie de errores menor. Cuanto más "puro" sea tu código, más fácil será razonar con él, lo que simplificará la depuración y las pruebas.

Concurrencia más segura

La concurrencia es una de las tareas más difíciles de hacer bien en el Java "clásico". Los conceptos funcionales te permiten eliminar muchos quebraderos de cabeza y obtener un procesamiento paralelo más seguro (casi) gratis.

Modularidad

Las funciones pequeñas e independientes facilitan la reutilización y la modularidad. Combinadas con la composición funcional y la aplicación parcial, dispones de potentes herramientas para construir fácilmente tareas más complejas a partir de estas partes más pequeñas.

Comprobabilidad

Muchos de los conceptos funcionales, como las funciones puras, la transparencia referencial, la inmutabilidad y la separación de preocupaciones, facilitan las pruebas y la verificación.

Desventajas de la programación funcional

Aunque la programación funcional tiene muchas ventajas, también es esencial conocer sus posibles escollos.

Curva de aprendizaje

La terminología y los conceptos matemáticos avanzados en los que se basa la programación funcional pueden resultar bastante intimidatorios. Sin embargo, para aumentar tu código Java, definitivamente no necesitas saber que "una mónada no es más que un monoide en la categoría de endofunctores".2"Sin embargo, te enfrentas a términos y conceptos nuevos y a menudo desconocidos.

Mayor nivel de abstracción

Mientras que la programación orientada a objetos utiliza objetos para modelar su abstracción, la FP utiliza un nivel superior de abstracción para representar sus estructuras de datos, lo que las hace bastante elegantes, pero a menudo más difíciles de reconocer.

Tratar con el Estado

Manejar el estado no es una tarea fácil, independientemente del paradigma elegido. Aunque el enfoque inmutable de FP elimina muchas posibles superficies de errores, también hace más difícil mutar estructuras de datos si realmente necesitan cambiar, especialmente si estás acostumbrado a tener setters en tu código OO.

Repercusiones en el rendimiento

La programación funcional es más fácil y segura de utilizar en entornos concurrentes. Sin embargo, esto no significa que sea intrínsecamente más rápida en comparación con otros paradigmas, especialmente en un contexto de un solo hilo. A pesar de sus muchas ventajas, muchas técnicas funcionales, como la inmutabilidad o la recursividad, pueden sufrir la sobrecarga necesaria. Por eso muchos lenguajes de PF utilizan una plétora de optimizaciones para mitigarla, como estructuras de datos especializadas que minimizan la copia, u optimizaciones del compilador para técnicas como la recursividad3.

Contexto óptimo del problema

No todos los contextos de problemas encajan bien con un enfoque funcional. Los dominios como la informática de alto rendimiento, los problemas de E/S pesada o los sistemas de bajo nivel y los controladores integrados, en los que necesitas un control detallado sobre aspectos como la localidad de datos y la gestión explícita de la memoria, no encajan bien con la programación funcional.

Como programadores, debemos encontrar el equilibrio entre las ventajas y los inconvenientes de cualquier paradigma y enfoque de programación. Por eso, este libro te muestra cómo elegir las mejores partes de la evolución funcional de Java y utilizarlas para aumentar tu código Java orientado a objetos.

Para llevar

  • La programación funcional se basa en el principio matemáticodel cálculo lambda.

  • Un estilo de codificación declarativo basado en expresiones en lugar de sentencias es esencial para la programación funcional.

  • Muchos conceptos de programación parecen inherentemente funcionales, pero no son un requisito absoluto para que un lenguaje o tu código sean "funcionales". Incluso el código no funcional se beneficia de sus ideas subyacentes y de su mentalidad general.

  • La pureza, la coherencia y la simplicidad son propiedades esenciales que debes aplicar a tu código para sacar el máximo partido de un enfoque funcional.

  • Puede que sea necesario hacer concesiones entre los conceptos funcionales y su aplicación en el mundo real, pero sus ventajas suelen superar a los inconvenientes, o al menos pueden mitigarse de alguna forma.

1 Alonzo Church, "Un problema irresoluble de la teoría elemental de números", American Journal of Mathematics, Vol. 58 (1936): 345-363.

2 James Iry utilizó esta frase en su entrada humorística del blog "Una historia breve, incompleta y en su mayor parte errónea de los lenguajes de programación" para ilustrar la complejidad de Haskell. También es un buen ejemplo de cómo no necesitas conocer todos los detalles matemáticos subyacentes de una técnica de programación para aprovechar sus ventajas. Pero si realmente quieres saber lo que significa, consulta el libro de Saunders Mac Lane, Categories for the Working Mathematician (Springer, 1998), donde se utilizó inicialmente la frase.

3 El artículo de Java Magazine "Curly Braces #6: Recursion and tail-call optimization" (llaves rizadas n.º 6: recursividad y optimización de la llamada de cola) proporciona una gran visión general sobre la importancia de la optimización de la llamada de cola en el código recursivo.

Get Un enfoque funcional de Java now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.