Estaba leyendo un artículo sobre programación funcional donde el escritor afirma
(take 25 (squares-of (integers)))
Tenga en cuenta que no tiene variables. De hecho, no tiene más que tres funciones y una constante. Intenta escribir los cuadrados de los enteros en Java sin usar una variable. Oh, probablemente haya una manera de hacerlo, pero ciertamente no es natural, y no se leería tan bien como mi programa anterior.
¿Es posible lograr esto en Java? Suponiendo que deba imprimir los cuadrados de los primeros 15 enteros, ¿podría escribir un bucle for o while sin usar variables?
Aviso de modificación
Esta pregunta no es un concurso de golf de código. Estamos buscando respuestas que expliquen los conceptos involucrados (idealmente sin repetir las respuestas anteriores), y no solo para otra pieza de código.
fuente
squaresOf(integers()).take(25)
(escribir esas funciones se deja como un ejercicio para el lector; la dificultad radica en el conjunto infinitointegers()
, pero eso es un problema para Java debido a su evaluación entusiasta, nada que ver con variables)Respuestas:
¿Es posible implementar tal ejemplo en Java sin usar actualizaciones destructivas? Si. Sin embargo, como mencionó @Thiton y el artículo en sí, será feo (dependiendo del gusto de cada uno). Una forma es usar la recursividad; Aquí hay un ejemplo de Haskell que hace algo similar:
Nota 1) la falta de mutación, 2) el uso de recursión, y 3) la falta de bucles. El último punto es muy importante: los lenguajes funcionales no necesitan construcciones de bucle incorporadas en el lenguaje, ya que la recursión se puede usar para la mayoría de los casos (¿todos?) En los que se usan bucles en Java. Aquí hay una serie de documentos bien conocidos que muestran cuán increíblemente expresivas pueden ser las llamadas a funciones.
El artículo me pareció insatisfactorio y me gustaría hacer un par de puntos adicionales:
Ese artículo es una explicación muy pobre y confusa de la programación funcional y sus beneficios. Recomiendo encarecidamente otras fuentes para aprender sobre programación funcional.
La parte más confusa sobre el artículo es que no menciona que hay dos usos para las declaraciones de asignación en Java (y la mayoría de los otros lenguajes principales):
enlazar un valor a un nombre:
final int MAX_SIZE = 100;
actualización destructiva:
int a = 3; a += 1; a++;
La programación funcional evita el segundo, pero abarca el primero (ejemplos:
let
-expresiones, parámetros de función,define
itiones de nivel superior ) . Este es un punto muy importante de entender, porque de lo contrario el artículo sólo parece tonto y podría dejarle con la duda, lo sontake
,squares-of
yintegers
si no las variables?Además, el ejemplo no tiene sentido. No muestra las implementaciones de
take
,squares-of
ointegers
. Por lo que sabemos, se implementan utilizando variables mutables. Como dijo @Martin, puedes escribir trivialmente este ejemplo en Java.Una vez más, recomendaría evitar este artículo y otros similares si realmente desea aprender sobre programación funcional. Parece estar escrito más con el objetivo de sorprender y ofender en lugar de enseñar conceptos y fundamentos. En cambio, ¿por qué no lees uno de mis papeles favoritos de todos los tiempos , de John Hughes? Hughes intenta abordar algunos de los mismos problemas que cubrió el artículo (aunque Hughes no habla sobre concurrencia / paralelización); Aquí hay un adelanto:
fuente
take 25 (map (^2) [1..])
como ejemplo?[1..]
? Esa es una característica genial integrada en Haskell, pero no ilustra los conceptos detrás de la generación de dicha lista. Estoy seguro de que las instancias de laEnum
clase (que requiere esa sintaxis) también son útiles, pero fue demasiado vago para encontrarlas. Por lo tanto,unfoldr
. :)No lo harías Las variables están en el centro de la programación imperativa, y si intentas programar imperativamente sin usar variables, simplemente estás causando molestias a todos. En diferentes paradigmas de programación, los estilos son diferentes, y diferentes conceptos forman su base. Una variable en Java, cuando se usa bien con un alcance pequeño, no es mala. Pedir un programa Java sin variables es como pedir un programa Haskell sin funciones, por lo que no lo solicita y no se deja engañar al ver la programación imperativa como inferior porque usa variables.
Entonces, la forma Java sería:
y no te dejes engañar para escribirlo de una manera más compleja debido al odio a las variables.
fuente
Lo más simple que puedo hacer con la recursión es una función con un parámetro. No es muy Java-esque, pero funciona:
fuente
En su ejemplo funcional, no vemos cómo se implementan las funciones
squares-of
ytake
. No soy un experto en Java, pero estoy bastante seguro de que podríamos escribir esas funciones para habilitar una declaración como esta ...que no es muy diferente
fuente
squares-of
no es un nombre válido en Java (squares_of
aunque sí lo es). Pero por lo demás, buen punto que muestra que el ejemplo del artículo es pobre.integer
genera perezosamente enteros, y latake
función selecciona 25 de lossquared-of
númerosinteger
. En resumen, debe tener unainteger
función para producir perezosamente enteros hasta el infinito.(integer)
una función: una función sigue siendo algo que asigna un argumento a un valor. Resulta que(integer)
no es una función, sino simplemente un valor. Incluso se podría ir tan lejos como para decir queinteger
es una variable que está vinculada a un número infinito de números.En Java podría hacer esto (especialmente la parte de la lista infinita) con iteradores. En el siguiente ejemplo de código, el número suministrado al
Take
constructor puede ser arbitrariamente alto.O con métodos de fábrica encadenables:
Dónde
SquaresOf
,Take
yIntegers
extenderNumbers
fuente
while (test.hasNext()) System.out.println(test.next())
serían un no-no en FP; Los iteradores son inherentemente mutables.Version corta:
Para que el estilo de asignación única funcione de manera confiable en Java, necesitaría (1) algún tipo de infraestructura amigable inmutable, y (2) soporte a nivel de tiempo de ejecución o compilador para la eliminación de llamadas de cola.
Podemos escribir gran parte de la infraestructura y podemos organizar cosas para tratar de evitar llenar la pila. Pero siempre que cada llamada tome un marco de pila, habrá un límite en la cantidad de recursión que puede hacer. Mantenga sus iterables pequeños y / o flojos, y no debería tener problemas importantes. Al menos la mayoría de los problemas con los que se encontrará no requieren devolver un millón de resultados a la vez. :)
También tenga en cuenta que, dado que el programa debe efectuar cambios visibles para que valga la pena ejecutarlo, no puede hacer que todo sea inmutable. Sin embargo, puede mantener la gran mayoría de sus propias cosas inmutables, utilizando un pequeño subconjunto de mutables esenciales (secuencias, por ejemplo) solo en ciertos puntos clave donde las alternativas serían demasiado onerosas.
Versión larga:
En pocas palabras, un programa Java no puede evitar totalmente las variables si quiere hacer algo que valga la pena. Puede contenerlos y, por lo tanto, restringir la mutabilidad en gran medida, pero el diseño mismo del lenguaje y la API, junto con la necesidad de cambiar eventualmente el sistema subyacente, hace que la inmutabilidad total sea inviable.
Java fue diseñado desde el principio como un imperativo , orientado a objetos lenguaje.
while (true)
yfor (;;)
! - dependen por completo de una variable en algún lugar que cambia de iteración a iteración.El resultado final de esas decisiones de diseño es que sin variables mutables, Java no tiene forma de cambiar el estado de nada, incluso algo tan simple como imprimir "¡Hola, mundo!" a la pantalla implica una secuencia de salida, que implica pegar bytes en un búfer mutable .
Entonces, a todos los efectos prácticos, estamos limitados a desterrar las variables de nuestro propio código. OK, podemos hacer eso. Casi. Básicamente, lo que necesitaríamos es reemplazar casi todas las iteraciones por recursividad, y todas las mutaciones con llamadas recursivas que devuelven el valor cambiado. al igual que...
Básicamente, construimos una lista vinculada, donde cada nodo es una lista en sí misma. Cada lista tiene una "cabeza" (el valor actual) y una "cola" (la sublista restante). La mayoría de los lenguajes funcionales hacen algo similar a esto, porque es muy susceptible a la inmutabilidad eficiente. Una operación "siguiente" simplemente devuelve la cola, que generalmente se pasa al siguiente nivel en una pila de llamadas recursivas.
Ahora, esta es una versión extremadamente simplificada de estas cosas. Pero es lo suficientemente bueno como para demostrar un problema grave con este enfoque en Java. Considera este código:
Aunque solo necesitamos 25 pulgadas para el resultado,
squares_of
no lo sabemos. Va a devolver el cuadrado de cada númerointegers
. La recursión de 20 millones de niveles de profundidad causa problemas bastante grandes en Java.Mire, los lenguajes funcionales en los que normalmente se comportaría de esta manera tienen una función llamada "eliminación de llamadas de cola". Lo que eso significa es que, cuando el compilador ve que el último acto del código es llamarse a sí mismo (y devolver el resultado si la función no es nula), usa el marco de la pila de la llamada actual en lugar de configurar uno nuevo y en su lugar hace un "salto". de una "llamada" (por lo que el espacio de pila utilizado permanece constante). En resumen, representa aproximadamente el 90% del camino para convertir la recursión de cola en iteración. Podría lidiar con esos mil millones de pulgadas sin desbordar la pila. (Con el tiempo, se quedaría sin memoria, pero reunir una lista de mil millones de ints te va a estropear la memoria de todos modos en un sistema de 32 bits).
Java no hace eso, en la mayoría de los casos. (Depende del compilador y del tiempo de ejecución, pero la implementación de Oracle no lo hace). Cada llamada a una función recursiva consume la memoria de un marco de pila. Usa demasiado y obtienes un desbordamiento de pila. Desborda la pila, pero garantiza la muerte del programa. Así que tenemos que asegurarnos de no hacer eso.
Una semi-solución ... evaluación perezosa. Todavía tenemos las limitaciones de la pila, pero pueden estar vinculadas a factores sobre los que tenemos más control. No tenemos que calcular un millón de pulgadas solo para devolver 25. :)
Así que construyamos una infraestructura de evaluación perezosa. (Este código se probó hace un tiempo, pero lo he modificado bastante desde entonces; lea la idea, no los errores de sintaxis. :))
(Tenga en cuenta que si esto fuera realmente viable en Java, un código al menos similar al anterior ya sería parte de la API).
Ahora, con una infraestructura en su lugar, es bastante trivial escribir código que no necesita variables mutables y que al menos sea estable para cantidades de entrada más pequeñas.
Esto funciona principalmente, pero todavía es algo propenso a desbordamientos de pila. Trate
take
ing 2 mil millones de enteros y haciendo algunas medidas al respecto. : P Eventualmente arrojará una excepción, al menos hasta que más de 64 GB de RAM se conviertan en estándar. El problema es que la cantidad de memoria de un programa que está reservada para su pila no es tan grande. Es típicamente entre 1 y 8 MiB. (Puede solicitar más grande, pero eso no importa tanto la cantidad que pide - se llamatake(1000000000, someInfiniteSequence)
, que va . Obtener una excepción) Afortunadamente, la evaluación perezosa, el punto débil se encuentra en una zona podemos controlar mejor . Solo tenemos que tener cuidado con la cantidad que tenemostake()
.Todavía tendrá muchos problemas para ampliar, porque nuestro uso de pila aumenta linealmente. Cada llamada maneja un elemento y pasa el resto a otra llamada. Ahora que lo pienso, sin embargo, hay un truco que podemos hacer que podría ganarnos un poco más de margen: convertir la cadena de llamadas en un árbol de llamadas. Considere algo más como esto:
workWith
básicamente divide el trabajo en dos mitades y asigna cada mitad a otra llamada a sí mismo. Como cada llamada reduce el tamaño de la lista de trabajo a la mitad en lugar de uno, esto debería escalar logarítmicamente en lugar de linealmente.El problema es que esta función quiere una entrada, y con una lista vinculada, obtener la longitud requiere recorrer toda la lista. Sin embargo, eso se resuelve fácilmente; simplemente no me importa cuántas entradas hay. :) El código anterior funcionaría con algo
Integer.MAX_VALUE
como el recuento, ya que un valor nulo detiene el procesamiento de todos modos. El conteo está principalmente allí, así que tenemos un caso base sólido. Si prevé tener más deInteger.MAX_VALUE
entradas en una lista, puede verificarworkWith
el valor de retorno, que debe ser nulo al final. De lo contrario, recurse.Tenga en cuenta que esto toca tantos elementos como usted le indique. No es vago; hace lo suyo de inmediato. Solo desea hacerlo para acciones , es decir, cosas cuyo único propósito es aplicarse a cada elemento de una lista. Como lo estoy pensando ahora, me parece que las secuencias serían mucho menos complicadas si se mantuvieran lineales; no debería ser un problema, ya que las secuencias no se llaman a sí mismas de todos modos, solo crean objetos que las llaman de nuevo.
fuente
Anteriormente intenté crear un intérprete para un lenguaje similar a lisp en Java (hace unos años y todo el código se perdió como estaba en CVS en sourceforge), y los iteradores de utilidades de Java son un poco detallados para la programación funcional en las listas
Aquí hay algo basado en una interfaz de secuencia, que solo tiene las dos operaciones que necesita para obtener el valor actual y comenzar la secuencia en el siguiente elemento. Estos se denominan cabeza y cola después de las funciones en el esquema.
Es importante usar algo como las interfaces
Seq
oIterator
ya que significa que la lista se crea perezosamente. LaIterator
interfaz no puede ser un objeto inmutable, por lo que es menos adecuada para la programación funcional: si no puede saber si el valor que pasa a una función ha cambiado, pierde una de las principales ventajas de la programación funcional.Obviamente
integers
debería ser una lista de todos los enteros, así que comencé en cero y alternativamente devolví los positivos y negativos.Hay dos versiones de cuadrados: uno crea una secuencia personalizada y el otro usa
map
una 'función': Java 7 no tiene lambdas, por lo que utilicé una interfaz, y la aplica a cada elemento de la secuencia.El objetivo de la
square ( int x )
función es solo eliminar la necesidad de llamarhead()
dos veces; normalmente lo hubiera hecho al poner el valor en una variable final, pero agregar esta función significa que no hay variables en el programa, solo parámetros de la función.La verbosidad de Java para este tipo de programación me llevó a escribir la segunda versión de mi intérprete en C99.
fuente
En teoría, puede implementar casi cualquier cosa en Java utilizando solo recursividad y sin variables mutables.
En la práctica:
El lenguaje Java no está diseñado para esto. Muchas construcciones están diseñadas para la mutación y son difíciles de usar sin ella. (Por ejemplo, no puede inicializar una matriz Java de longitud variable sin mutación).
Lo mismo para las bibliotecas. Y si te limitas a clases de biblioteca que no usan mutación debajo de la tapa, es aún más difícil. (Ni siquiera puedes usar String ... mira cómo
hashcode
se implementa).Las implementaciones principales de Java no admiten la optimización de llamadas de cola. Eso significa que las versiones recursivas de algoritmos tienden a ser espacio de pila "hambriento". Y como las pilas de subprocesos de Java no crecen, debe preasignar grandes pilas ... o arriesgarse
StackOverflowError
.Combine estas tres cosas, y Java no es realmente una opción viable para escribir programas útiles (es decir, no triviales) sin variables mutables.
(Pero bueno, está bien. Hay otros lenguajes de programación disponibles para la JVM, algunos de los cuales admiten la programación funcional).
fuente
Como estamos buscando un ejemplo de los conceptos, diría que dejemos de lado Java y busquemos una configuración diferente pero familiar en la que encontrar una versión familiar de los conceptos. Las tuberías UNIX son bastante similares a encadenar funciones perezosas.
En Linux, esto significa, dame bytes, cada uno de los cuales está compuesto de bits falsos en lugar de verdaderos, hasta que pierda el apetito; cambie cada uno de esos bytes a un carácter de nueva línea; numerar cada línea así creada; y produce el cuadrado de ese número. Además tengo apetito por 25 líneas y no más.
Afirmo que un programador no estaría mal aconsejado para escribir una tubería de Linux de esa manera. Es una secuencia de comandos de shell de Linux relativamente normal.
Afirmo que un programador no debería intentar escribir lo mismo de manera similar en Java. La razón es el mantenimiento del software como un factor importante en el costo de por vida de los proyectos de software. No queremos confundir al próximo programador presentando lo que aparentemente es un programa Java pero que en realidad está escrito en un lenguaje único personalizado al duplicar elaboradamente la funcionalidad que ya existe en la plataforma Java.
Por otro lado, afirmo que el próximo programador podría aceptar más si algunos de nuestros paquetes "Java" en realidad son paquetes de Java Virtual Machine escritos en uno de los lenguajes funcionales u objetos / funcionales como Clojure y Scala. Estos están diseñados para codificarse mediante el encadenamiento de funciones y para ser llamados desde Java de la manera normal de las llamadas a métodos Java.
Por otra parte, aún puede ser una buena idea que un programador de Java se inspire en la programación funcional, en algunos lugares.
Recientemente, mi técnica favorita [era] usar una variable de retorno inmutable, no inicializada y una única salida para que, como hacen algunos compiladores de lenguaje funcional, Java compruebe que no importa lo que ocurra en el cuerpo de la función, siempre proporciono una y solo una valor de retorno Ejemplo:fuente
int result = -n; if (n < 1) { result = 0 } return result;
se compila muy bien y el compilador no tiene idea de si tenía la intención de hacerlo equivalente a la función en mi ejemplo. Tal vez ese ejemplo es demasiado simple para hacer que la técnica parezca útil, pero en una función con muchas ramas, creo que es bueno dejar en claro que el resultado se asigna exactamente una vez, independientemente de la ruta que se siga.if (n < 1) return 0; else return -n;
embargo, si dices , terminas sin ningún problema ... y además es más simple. :) Me parece que en ese caso, la regla de "un retorno" en realidad ayuda a causar el problema de no saber cuándo se estableció el valor de retorno; de lo contrario, podría devolverlo y Java sería más capaz de determinar cuándo otras rutas podrían no devolver un valor, porque ya no se está divorciando del cálculo del valor de la devolución real del mismo.if (n < 0) return -n; else if (n == 0) return 0; else return n - 1;
.La forma más fácil de averiguarlo sería enviar lo siguiente al compilador de Frege y observar el código Java generado:
fuente