¿Ideas erróneas sobre lenguajes puramente funcionales?

39

A menudo encuentro las siguientes declaraciones / argumentos:

  1. Los lenguajes de programación funcionales puros no permiten efectos secundarios (y, por lo tanto, son de poca utilidad en la práctica porque cualquier programa útil sí tiene efectos secundarios, por ejemplo, cuando interactúa con el mundo externo).
  2. Los lenguajes de programación funcionales puros no permiten escribir un programa que mantenga el estado (lo que hace que la programación sea muy incómoda porque en muchas aplicaciones se necesita estado).

No soy un experto en lenguajes funcionales, pero esto es lo que he entendido sobre estos temas hasta ahora.

Con respecto al punto 1, puede interactuar con el entorno en lenguajes puramente funcionales, pero debe marcar explícitamente el código (funciones) que introduce efectos secundarios (por ejemplo, en Haskell por medio de tipos monádicos). Además, hasta donde yo sé, la computación por efectos secundarios (actualización destructiva de datos) también debería ser posible (¿usando tipos monádicos?), Aunque no es la forma preferida de trabajar.

Con respecto al punto 2, hasta donde yo sé, puede representar el estado al enhebrar valores a través de varios pasos de cálculo (en Haskell, nuevamente, usando tipos monádicos), pero no tengo experiencia práctica en hacerlo y mi comprensión es bastante vaga.

Entonces, ¿las dos afirmaciones anteriores son correctas en algún sentido o son simplemente conceptos erróneos sobre lenguajes puramente funcionales? Si son ideas falsas, ¿cómo surgieron? ¿Podría escribir un fragmento de código (posiblemente pequeño) que ilustre la forma idiomática de Haskell de (1) implementar efectos secundarios e (2) implementar un cálculo con estado?

Giorgio
fuente
77
Creo que la mayor parte de esto depende de lo que define un lenguaje funcional 'puro'.
jk.
@jk: Para evitar el problema de definir lenguajes funcionales 'puros', asuma la pureza en el sentido de Haskell (que está bien definido). Bajo qué condiciones un lenguaje funcional puede considerarse 'puro' puede ser el tema de una pregunta futura.
Giorgio
Ambas respuestas contienen muchas ideas aclaratorias y me fue difícil elegir cuál aceptar. Decidí aceptar la respuesta de sepp2k debido a los ejemplos de pseudocódigo adicionales.
Giorgio

Respuestas:

26

A los fines de esta respuesta, defino "lenguaje puramente funcional" para significar un lenguaje funcional en el que las funciones son referencialmente transparentes, es decir, llamar a la misma función varias veces con los mismos argumentos siempre producirá los mismos resultados. Esta es, creo, la definición habitual de un lenguaje puramente funcional.

Los lenguajes de programación funcionales puros no permiten efectos secundarios (y, por lo tanto, son de poca utilidad en la práctica porque cualquier programa útil sí tiene efectos secundarios, por ejemplo, cuando interactúa con el mundo externo).

La forma más fácil de lograr la transparencia referencial sería, de hecho, no permitir los efectos secundarios y, de hecho, hay idiomas en los que ese es el caso (en su mayoría, dominios específicos). Sin embargo, ciertamente no es la única forma y la mayoría de los lenguajes puramente funcionales (Haskell, Clean, ...) permiten efectos secundarios.

También creo que decir que un lenguaje de programación sin efectos secundarios es poco útil en la práctica no es realmente justo, ciertamente no para lenguajes específicos de dominio, pero incluso para lenguajes de propósito general, me imagino que un lenguaje puede ser bastante útil sin proporcionar efectos secundarios . Quizás no para aplicaciones de consola, pero creo que las aplicaciones GUI pueden implementarse sin efectos secundarios en, por ejemplo, el paradigma funcional reactivo.

Con respecto al punto 1, puede interactuar con el entorno en lenguajes puramente funcionales, pero debe marcar explícitamente el código (funciones) que los introduce (por ejemplo, en Haskell mediante tipos monádicos).

Eso es un poco más que simplificarlo. El simple hecho de tener un sistema donde las funciones de efectos secundarios deban marcarse como tales (similar a la corrección constante en C ++, pero con efectos secundarios generales) no es suficiente para garantizar la transparencia referencial. Debe asegurarse de que un programa nunca pueda llamar a una función varias veces con los mismos argumentos y obtener resultados diferentes. Podrías hacer eso haciendo cosas comoreadLinesea ​​algo que no sea una función (eso es lo que hace Haskell con la mónada IO) o podría hacer que sea imposible llamar a las funciones de efecto secundario varias veces con el mismo argumento (eso es lo que hace Clean). En el último caso, el compilador se aseguraría de que cada vez que llame a una función de efectos secundarios, lo haga con un argumento nuevo, y rechazaría cualquier programa en el que pase dos veces el mismo argumento a una función de efectos secundarios.

Los lenguajes de programación funcionales puros no permiten escribir un programa que mantenga el estado (lo que hace que la programación sea muy incómoda porque en muchas aplicaciones se necesita estado).

Una vez más, un lenguaje puramente funcional podría no permitir el estado mutable, pero ciertamente es posible ser puro y tener un estado mutable, si lo implementa de la misma manera que describí con los efectos secundarios anteriores. El estado realmente mutable es solo otra forma de efectos secundarios.

Dicho esto, los lenguajes de programación funcionales definitivamente desalientan el estado mutable, especialmente los puros. Y no creo que eso haga que la programación sea incómoda, sino todo lo contrario. A veces (pero no con tanta frecuencia) el estado mutable no se puede evitar sin perder rendimiento o claridad (es por eso que los lenguajes como Haskell tienen facilidades para el estado mutable), pero la mayoría de las veces sí se puede.

Si son ideas falsas, ¿cómo surgieron?

Creo que muchas personas simplemente leen "una función debe producir el mismo resultado cuando se llama con los mismos argumentos" y concluyen que no es posible implementar algo como readLineo código que mantenga un estado mutable. Por lo tanto, simplemente no son conscientes de los "trucos" que los lenguajes puramente funcionales pueden usar para introducir estas cosas sin romper la transparencia referencial.

También el estado mutable es muy desalentador en los lenguajes funcionales, por lo que no es un gran salto suponer que no está permitido en absoluto en los lenguajes puramente funcionales.

¿Podría escribir un fragmento de código (posiblemente pequeño) que ilustre la forma idiomática de Haskell de (1) implementar efectos secundarios e (2) implementar un cálculo con estado?

Aquí hay una aplicación en Pseudo-Haskell que le pide al usuario un nombre y lo saluda. Pseudo-Haskell es un lenguaje que acabo de inventar, que tiene el sistema IO de Haskell, pero usa una sintaxis más convencional, nombres de funciones más descriptivos y no tiene doanotación (ya que eso solo distraería de cómo funciona exactamente la mónada IO):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

La pista aquí es que readLinees un valor de tipo IO<String>y composeMonades una función que toma un argumento de tipo IO<T>(para algún tipo T) y otro argumento que es una función que toma un argumento de tipo Ty devuelve un valor de tipo IO<U>(para algún tipo U). printes una función que toma una cadena y devuelve un valor de tipo IO<void>.

Un valor de tipo IO<A>es un valor que "codifica" una acción dada que produce un valor de tipo A. composeMonad(m, f)produce un nuevo IOvalor que codifica la acción de mseguido por la acción de f(x), donde xes el valor que produce al realizar la acción de m.

El estado mutable se vería así:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

Aquí mutableVariablehay una función que toma valor de cualquier tipo Ty produce a MutableVariable<T>. La función getValuetoma MutableVariabley devuelve un IO<T>que produce su valor actual. setValuetoma una MutableVariable<T>y una Ty devuelve una IO<void>que establece el valor. composeVoidMonades lo mismo que composeMonadexcepto que el primer argumento es un IOque no produce un valor sensible y el segundo argumento es otra mónada, no una función que devuelve una mónada.

En Haskell hay algo de azúcar sintáctica, que hace que esta prueba sea menos dolorosa, pero aún es obvio que el estado mutable es algo que el lenguaje realmente no quiere que hagas.

sepp2k
fuente
Gran respuesta, aclarando muchas ideas. ¿Debería la última línea del fragmento de código usar el nombre counter, es decir increaseCounter(counter)?
Giorgio
@ Jorge Sí, debería. Fijo.
sepp2k
1
@Giorgio Una cosa que olvidé mencionar explícitamente en mi publicación es que la acción de IO devuelta mainserá la que realmente se ejecute. Aparte de devolver un IO desde mainallí, no hay forma de ejecutar IOacciones (sin usar funciones terriblemente malvadas que tienen unsafeen su nombre).
sepp2k
OKAY. Scarfridge también mencionó IOvalores destructivos . No entendí si se refiere a la coincidencia de patrones, es decir, al hecho de que puede deconstruir valores de un tipo de datos algebraicos, pero uno no puede usar la coincidencia de patrones para hacer esto con los IOvalores.
Giorgio
16

En mi humilde opinión, estás confundido porque hay una diferencia entre un lenguaje puro y una función pura . Comencemos con la función. Una función es pura si (con la misma entrada) siempre devuelve el mismo valor y no causa ningún efecto secundario observable. Ejemplos típicos son funciones matemáticas como f (x) = x * x. Ahora considere una implementación de esta función. Sería puro en la mayoría de los idiomas, incluso en aquellos que generalmente no se consideran lenguajes funcionales puros, por ejemplo, ML. Incluso un método Java o C ++ con este comportamiento puede considerarse puro.

Entonces, ¿qué es un lenguaje puro? Estrictamente hablando, uno podría esperar que un lenguaje puro no le permita expresar funciones que no son puras. Llamemos a esto la definición idealista de un lenguaje puro. Tal comportamiento es altamente deseable. ¿Por qué? Bueno, lo bueno de un programa que consiste solo de funciones puras es que puede reemplazar la aplicación de función con su valor sin cambiar el significado del programa. Esto hace que sea muy fácil razonar sobre los programas porque una vez que conoce el resultado, puede olvidar la forma en que se calculó. La pureza también puede permitir que el compilador realice ciertas optimizaciones agresivas.

¿Y qué si necesitas un estado interno? Puede imitar el estado en un lenguaje puro simplemente agregando el estado antes del cálculo como parámetro de entrada y el estado después del cálculo como parte del resultado. En lugar de Int -> Boolobtener algo como Int -> State -> (Bool, State). Simplemente hace explícita la dependencia (que se considera una buena práctica en cualquier paradigma de programación). Por cierto, hay una mónada que es una forma particularmente elegante de combinar tales funciones de imitación de estado en funciones de imitación de estado más grandes. De esta manera definitivamente puedes "mantener el estado" en un lenguaje puro. Pero tienes que hacerlo explícito.

Entonces, ¿esto significa que puedo interactuar con el exterior? Después de todo, un programa útil debe interactuar con el mundo real para ser útil. Pero la entrada y la salida obviamente no son puras. Escribir un byte específico en un archivo específico podría estar bien por primera vez. Pero ejecutar exactamente la misma operación por segunda vez podría devolver un error porque el disco está lleno. Claramente, no existe un lenguaje puro (en el significado idealista) que pueda escribir en un archivo.

Entonces nos enfrentamos a un dilema. Queremos funciones principalmente puras, pero algunos efectos secundarios son absolutamente necesarios y esos no son puros. Ahora, una definición realista de un lenguaje puro sería que tiene que haber algún medio para separar las partes puras de las otras partes. El mecanismo debe garantizar que ninguna operación impura se cuela en las partes puras.

En Haskell esto se hace con el tipo IO. No puede destruir un resultado IO (sin mecanismos inseguros). Por lo tanto, solo puede procesar resultados de E / S con funciones definidas en el propio módulo de E / S. Afortunadamente, hay un combinador muy flexible que le permite tomar un resultado IO y procesarlo en una función siempre que esa función devuelva otro resultado IO. Este combinador se llama enlace (o >>=) y tiene el tipo IO a -> (a -> IO b) -> IO b. Si generaliza este concepto, llega a la clase mónada y resulta que IO es una instancia de él.

Scarfridge
fuente
44
Realmente no veo cómo Haskell (ignorando cualquier función con unsafesu nombre) no cumple con su definición idealista. No hay funciones impuras en Haskell (nuevamente ignorando unsafePerformIOy co.).
sepp2k
44
readFiley writeFilesiempre devolverá el mismo IOvalor, dados los mismos argumentos. Entonces, por ejemplo, los dos fragmentos de código let x = writeFile "foo.txt" "bar" in x >> xy writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"harán lo mismo.
sepp2k
3
@AidanCully ¿Qué quieres decir con "función IO"? ¿Una función que devuelve un valor de tipo IO Something? Si es así, es perfectamente posible llamar a una función IO dos veces con el mismo argumento: putStrLn "hello" >> putStrLn "hello"aquí ambas llamadas putStrLntienen el mismo argumento. Por supuesto, eso no es un problema porque, como dije antes, ambas llamadas darán como resultado el mismo valor de IO.
sepp2k
3
@scarfridge Evaluar writeFile "foo.txt" "bar"no puede causar un error porque evaluar la llamada a la función no ejecuta la acción. Si está diciendo que en mi ejemplo anterior, la versión con letsolo tiene una oportunidad de causar una falla de E / S mientras que la versión sin lettiene dos, está equivocado. Ambas versiones tienen dos oportunidades para una falla de E / S. Como la letversión evalúa la llamada writeFilesolo una vez, mientras que la versión sin la letevalúa dos veces, puede ver que no importa con qué frecuencia se llama a la función. Solo importa con qué frecuencia el resultado ...
sepp2k
66
@AidanCully El "mecanismo de mónada" no pasa parámetros implícitos. La putStrLnfunción toma exactamente un argumento, que es de tipo String. Si no me creen, miren en su tipo: String -> IO (). Ciertamente no toma ningún argumento de tipo IO, produce un valor de ese tipo.
sepp2k