¿Cómo se gestiona un lenguaje de programación funcional puro sin sentencias de asignación?

26

Al leer el famoso SICP, descubrí que los autores parecen bastante reacios a presentar la declaración de asignación al Esquema en el Capítulo 3. Leí el texto y comprendo por qué se sienten así.

Como Scheme es el primer lenguaje de programación funcional del que conozco algo, me sorprende que haya algunos lenguajes de programación funcionales (no Scheme, por supuesto) que pueden funcionar sin asignaciones.

Usemos el ejemplo que ofrece el libro, el bank accountejemplo. Si no hay una declaración de asignación, ¿cómo se puede hacer esto? ¿Cómo cambiar la balancevariable? Lo pregunto porque sé que hay algunos llamados lenguajes funcionales puros y, según la teoría completa de Turing, esto también se puede hacer.

Aprendí C, Java, Python y uso muchas tareas en cada programa que escribí. Entonces es realmente una experiencia reveladora. Realmente espero que alguien pueda explicar brevemente cómo se evitan las asignaciones en esos lenguajes de programación funcionales y qué profundo impacto (si lo hay) tiene en estos lenguajes.

El ejemplo mencionado anteriormente está aquí:

(define (make-withdraw balance)
    (lambda (amount)
        (if (>= balance amount)
            (begin (set! balance (- balance amount))
                balance)
            "Insufficient funds")))

Esto cambió el balancepor set!. Para mí, se parece mucho a un método de clase para cambiar el miembro de la clase balance.

Como dije, no estoy familiarizado con los lenguajes de programación funcionales, por lo que si digo algo malo sobre ellos, no dudes en señalarlo.

Gnijuohz
fuente
1
Con respecto al aprendizaje de un lenguaje puramente funcional: no recomendaría necesariamente hacerlo de inmediato. Si aprende Haskell, además de aprender a escribir programas sin variables mutables, también tendrá que aprender sobre la pereza y la forma en que Haskell realiza IO. Eso podría ser un poco demasiado de una vez. Si desea aprender a escribir programas sin estado mutable, la forma más fácil probablemente sería escribir un montón de programas de esquema sin usar set!u otras funciones que terminen con a !. Una vez que te sientas cómodo con eso, la transición a FP pura debería ser más fácil.
sepp2k

Respuestas:

21

Si no hay una declaración de asignación, ¿cómo se puede hacer esto? ¿Cómo cambiar la variable de saldo?

No puede cambiar las variables sin algún tipo de operador de asignación.

Lo pregunto porque sé que hay algunos llamados lenguajes funcionales puros y, según la teoría completa de Turing, esto también se puede hacer.

No exactamente. Si un idioma es Turing completo, eso significa que puede calcular cualquier cosa que cualquier otro lenguaje completo de Turing pueda calcular. No significa que tenga que tener todas las características que tienen otros idiomas.

No es una contradicción que un lenguaje de programación completo de Turing no tenga forma de cambiar el valor de una variable, siempre que para cada programa que tenga variables mutables, pueda escribir un programa equivalente que no tenga variables mutables (donde "equivalente" significa que calcula lo mismo). Y, de hecho, cada programa se puede escribir de esa manera.

Con respecto a su ejemplo: en un lenguaje puramente funcional, simplemente no podría escribir una función que devuelva un saldo de cuenta diferente cada vez que se llame. Pero aún podría reescribir cada programa, que utiliza dicha función, de una manera diferente.


Como solicitó un ejemplo, consideremos un programa imperativo que utiliza su función de extracción / extracción (en pseudocódigo). Este programa le permite al usuario retirarse de una cuenta, depositar en ella o consultar la cantidad de dinero en la cuenta:

account = make-withdraw(0)
ask for input until the user enters "quit"
    if the user entered "withdraw $x"
        account(x)
    if the user entered "deposit $x"
        account(-x)
    if the user entered "query"
        print("The balance of the account is " + account(0))

Aquí hay una manera de escribir el mismo programa sin usar variables mutables (no me molestaré con IO transparente referencial porque la pregunta no era sobre eso):

function IO_loop(balance):
    ask for input
    if the user entered "withdraw $x"
        IO_loop(balance - x)
    if the user entered "deposit $x"
        IO_loop(balance + x)
    if the user entered "query"
        print("The balance of the account is " + balance)
        IO_loop(balance)
    if the user entered "quit"
        do nothing

 IO_loop(0)

La misma función también podría escribirse sin usar la recursión mediante el uso de un pliegue sobre la entrada del usuario (que sería más idiomático que la recursividad explícita), pero aún no sé si está familiarizado con los pliegues, así que lo escribí en un de manera que no use nada que aún no conozca.

sepp2k
fuente
Puedo ver su punto, pero déjenme ver que quiero un programa que también simule la cuenta bancaria y también pueda hacer estas cosas (retirar y depositar), entonces, ¿hay alguna manera fácil de hacerlo?
Gnijuohz
@Gnijuohz Siempre depende de qué problema estás tratando de resolver exactamente. Por ejemplo, si tiene un saldo inicial y una lista de retiros y depósitos y desea conocer el saldo después de esos retiros y depósitos, simplemente puede calcular la suma de los depósitos menos la suma de los retiros y agregar eso al saldo inicial . Entonces en el código que sería newBalance = startingBalance + sum(deposits) - sum(withdrawals).
sepp2k
1
@Gnijuohz He agregado un programa de ejemplo a mi respuesta.
sepp2k
¡Gracias por el tiempo y los esfuerzos que dedicó a escribir y reescribir la respuesta! :)
Gnijuohz
Agregaría que usar la continuación también podría ser un medio para lograr eso en el esquema (siempre y cuando pueda pasar un argumento a la continuación)
dader51
11

Tienes razón en que se parece mucho a un método en un objeto. Eso es porque eso es esencialmente lo que es. La lambdafunción es un cierre que atrae la variable externa balancea su alcance. Tener múltiples cierres que se cierran sobre las mismas variables externas y tener múltiples métodos en el mismo objeto son dos abstracciones diferentes para hacer exactamente lo mismo, y cualquiera de las dos puede implementarse en términos de la otra si comprende ambos paradigmas.

La forma en que los lenguajes funcionales puros manejan el estado es haciendo trampa. Por ejemplo, en Haskell si desea leer la entrada de una fuente externa, (que no es determinista, por supuesto, y no necesariamente dará el mismo resultado dos veces si la repite), utiliza un truco de mónada para decir "hemos obtuve esta otra variable simulada que representa el estado de todo el resto del mundo , y no podemos examinarla directamente, pero leer la entrada es una función pura que toma el estado del mundo exterior y devuelve la entrada determinista que ese estado exacto siempre se representará, más el nuevo estado del mundo exterior ". (Esa es una explicación simplificada, por supuesto. Leer sobre la forma en que funciona realmente te romperá el cerebro).

O, en el caso de su problema de cuenta bancaria, en lugar de asignar un nuevo valor a la variable, puede devolver el nuevo valor como resultado de la función, y luego la persona que llama tiene que lidiar con él en un estilo funcional, generalmente recreando cualquier información que hace referencia a ese valor con una nueva versión que contiene el valor actualizado. (Esta no es una operación tan voluminosa como podría parecer si sus datos están configurados con el tipo correcto de estructura de árbol).

Mason Wheeler
fuente
Estoy realmente interesado en nuestra respuesta y el ejemplo de Haskell, pero debido a la falta de conocimiento al respecto, no puedo entender completamente la última parte de su respuesta (bueno, también la segunda parte :()
Gnijuohz
3
@Gnijuohz El último párrafo dice que, en lugar de hacerlo b = makeWithdraw(42); b(1); b(2); b(3); print(b(4)), puede hacer b = 42; b1 = withdraw(b1, 1); b2 = withdraw(b1, 2); b3 = withdraw(b2, 3); print(withdraw(b3, 4));donde withdrawsimplemente se define como withdraw(balance, amount) = balance - amount.
sepp2k
3

Los "operadores de asignación múltiple" son un ejemplo de una característica del lenguaje que, en términos generales, tiene efectos secundarios y es incompatible con algunas propiedades útiles de los lenguajes funcionales (como la evaluación diferida).

Sin embargo, eso no significa que la asignación en general sea incompatible con un estilo de programación funcional puro (ver esta discusión, por ejemplo), ni tampoco que no se pueda construir una sintaxis que permita acciones que parecen tareas en general, pero se implementan sin efectos secundarios. Sin embargo, crear ese tipo de sintaxis y escribir programas eficientes en él lleva mucho tiempo y es difícil.

En su ejemplo específico, tiene razón: ¡el conjunto! El operador es una tarea. Es no un operador libre de efectos secundarios, y es un lugar donde se rompe Esquema con un enfoque puramente funcional a la programación.

En última instancia, cualquier lenguaje puramente funcional va a tener que romper con el enfoque puramente funcional en algún momento - la gran mayoría de los programas útiles hacerlo tiene efectos secundarios. La decisión de dónde hacerlo suele ser una cuestión de conveniencia, y los diseñadores de lenguaje intentarán darle al programador la mayor flexibilidad para decidir dónde romper con un enfoque puramente funcional, según corresponda para su programa y dominio del problema.

blueberryfields
fuente
"En última instancia, cualquier lenguaje puramente funcional tendrá que romper con el enfoque puramente funcional en algún momento: la gran mayoría de los programas útiles tienen efectos secundarios" Es cierto, pero entonces estás hablando de hacer IO y demás. Se pueden escribir muchos programas útiles sin variables mutables.
sepp2k
1
... y por "la gran mayoría" de los programas útiles, te refieres a "todos", ¿verdad? Tengo dificultades incluso para imaginar la posibilidad de la existencia de cualquier programa que pueda llamarse razonablemente "útil" que no realice E / S, un acto que requiere efectos secundarios en ambas direcciones.
Mason Wheeler
Los programas SQL @MasonWheeler no hacen IO como tales. Tampoco es raro escribir un montón de funciones que no hacen IO en un lenguaje que tiene un REPL y luego simplemente llamarlas desde un REPL. Esto puede ser perfectamente útil si su público objetivo es capaz de usar REPL (especialmente si su público objetivo es usted).
sepp2k
1
@MasonWheeler: solo un contraejemplo simple y simple: calcular conceptualmente n dígitos de pi no requiere ninguna E / S. Es "solo" matemática y variables. La única entrada requerida es n y el valor de retorno es Pi (a n dígitos).
Joachim Sauer
1
@Joachim Sauer eventualmente querrá imprimir el resultado en la pantalla o informarlo al usuario. E inicialmente querrás cargar algunas constantes en el programa desde algún lugar. Entonces, si quieres ser pedante, todos los programas útiles tienen que hacer IO en algún momento, incluso si se trata de casos triviales implícitos y siempre ocultos para el programador por el entorno
blueberryfields
3

En un lenguaje puramente funcional, uno programaría un objeto de cuenta bancaria como una función de transformador de flujo. El objeto se considera como una función desde un flujo infinito de solicitudes de los propietarios de la cuenta (o de quien sea) hasta un flujo potencialmente infinito de respuestas. La función comienza con un saldo inicial y procesa cada solicitud en el flujo de entrada para calcular un nuevo saldo, que luego se retroalimenta a la llamada recursiva para procesar el resto del flujo. (Recuerdo que SICP discute el paradigma del transformador de flujo en otra parte del libro).

Una versión más elaborada de este paradigma se llama "programación reactiva funcional" discutida aquí en StackOverflow .

La ingenua manera de hacer transformadores de corriente tiene algunos problemas. Es posible (de hecho, bastante fácil) escribir programas con errores que mantengan todas las solicitudes antiguas, desperdiciando espacio. Más en serio, es posible hacer que la respuesta a la solicitud actual dependa de solicitudes futuras. Las soluciones a estos problemas se están trabajando actualmente. Neel Krishnaswami es la fuerza detrás de ellos.

Descargo de responsabilidad : no pertenezco a la iglesia de la programación funcional pura. De hecho, no pertenezco a ninguna iglesia :-)

Uday Reddy
fuente
¿Supongo que perteneces a algún templo? :-P
Gnijuohz
1
El templo del libre pensamiento. No hay predicadores allí.
Uday Reddy
2

No es posible hacer que un programa sea 100% funcional si se supone que debe hacer algo útil. (Si no se necesitan efectos secundarios, entonces todo el pensamiento podría haberse reducido a un tiempo de compilación constante) Al igual que el ejemplo de retirada, puede hacer que la mayoría de los procedimientos funcionen, pero eventualmente necesitará procedimientos que tengan efectos secundarios (entrada del usuario, salida a la consola). Dicho esto, puede hacer que la mayor parte de su código sea funcional y esa parte será fácil de probar, incluso automáticamente. Luego crea un código imperativo para hacer la entrada / salida / base de datos / ... que necesitaría depuración, pero mantener la mayor parte del código limpio no será demasiado trabajo. Usaré su ejemplo de retirada:

(define +no-founds+ "Insufficient funds")

;; functional withdraw
(define (make-withdraw balance amount)
    (if (>= balance amount)
        (- balance amount)
        +no-founds+))

;; functional atm loop
(define (atm balance thunk)
  (let* ((amount (thunk balance)) 
         (new-balance (make-withdraw balance amount)))
    (if (eqv? new-balance +no-founds+)
        (cons +no-founds+ '())
        (cons (list 'withdraw amount 'balance new-balance) (atm new-balance thunk)))))

;; functional balance-line -> string 
(define (balance->string x)
  (if (eqv? x +no-founds+)
      (string-append +no-founds+ "\n")
      (if (null? x)
          "\n"
          (let ((first-token (car x)))
            (string-append
             (cond ((symbol? first-token) (symbol->string first-token))
                   (else (number->string first-token)))
             " "
             (balance->string (cdr x)))))))

;; functional thunk to test  
(define (input-10 x) 10) ;; define a purly functional input-method

;; since all procedures involved are functional 
;; we expect the same result every time.
;; we use this to test atm and make-withdraw
(apply string-append (map balance->string (atm 100 input-10)))

;; no program can be purly functional in any language.
;; From here on there are imperative dirty procedures!

;; A procedure to get input from user is needed. 
;; Side effects makes it imperative
(define (user-input balance)
  (display "You have $")
  (display balance)
  (display " founds. How much to withdraw? ")
  (read))

;; We need a procedure to print stuff to the console 
;; as well. Side effects makes it imperative
(define (pretty-print-result x)
  (for-each (lambda (x) (display (balance->string x))) x))

;; use imperative procedure with atm.
(pretty-print-result (atm 100 user-input))

Es posible hacer lo mismo en casi cualquier idioma y producir los mismos resultados (menos errores), aunque es posible que tenga que establecer variables temporales dentro de un procedimiento e incluso mutar cosas, pero eso no importa demasiado mientras el procedimiento en realidad actúa funcional (solo los parámetros determinan el resultado). Creo que te conviertes en un mejor programador en cualquier idioma después de haber programado un poco de LISP :)

Sylwester
fuente
+1 por el extenso ejemplo y las explicaciones realistas sobre las partes funcionales y las partes funcionales no puras del programa y mencionar por qué la FP es importante.
Zelphir Kaltstahl el
1

La asignación es una operación incorrecta porque divide el espacio de estado en dos partes, antes de la asignación y después de la asignación. Esto causa dificultades para rastrear cómo se cambian las variables durante la ejecución del programa. Las siguientes cosas en lenguajes funcionales están reemplazando las asignaciones:

  1. Parámetros de función vinculados directamente a valores de retorno
  2. elegir diferentes objetos para devolver en lugar de modificar objetos existentes.
  3. crear nuevos valores evaluados de forma diferida
  4. enumerando todos los objetos posibles , no solo los que necesitan estar en la memoria
  5. sin efectos secundarios
tp1
fuente
Esto no parece abordar la pregunta planteada. ¿Cómo se programa un objeto de cuenta bancaria en un lenguaje funcional puro?
Uday Reddy
son funciones que se transforman de un registro de cuenta bancaria a otro. La clave es que cuando ocurren tales transformaciones, se eligen nuevos objetos en lugar de modificar los existentes.
tp1
Cuando transforma un registro de cuenta bancaria en otro, desea que el cliente realice la siguiente transacción en el nuevo registro, no en el anterior. El "punto de contacto" para el cliente debe actualizarse constantemente para que apunte al registro actual. Esa es una idea fundamental de "modificación". Los "objetos" de cuentas bancarias no son registros de cuentas bancarias.
Uday Reddy