Equívocos sobre linguagens puramente funcionais?

39

Costumo encontrar as seguintes declarações / argumentos:

  1. Linguagens de programação funcionais puras não permitem efeitos colaterais (e, portanto, são pouco úteis na prática, porque qualquer programa útil tem efeitos colaterais, por exemplo, quando interage com o mundo externo).
  2. Linguagens de programação funcionais puras não permitem escrever um programa que mantenha estado (o que torna a programação muito incômoda porque em muitos aplicativos você precisa de estado).

Eu não sou especialista em linguagens funcionais, mas aqui está o que eu entendi sobre esses tópicos até agora.

Em relação ao ponto 1, você pode interagir com o ambiente em linguagens puramente funcionais, mas é necessário marcar explicitamente o código (funções) que introduz efeitos colaterais (por exemplo, em Haskell por meio de tipos monádicos). Além disso, até onde sei a computação por efeitos colaterais (atualização destrutiva dos dados) também deve ser possível (usando tipos monádicos?), Mesmo que não seja a maneira preferida de trabalhar.

Em relação ao ponto 2, até onde eu sei, você pode representar o estado encadeando valores através de várias etapas de cálculo (em Haskell, novamente, usando tipos monádicos), mas não tenho experiência prática nisso e meu entendimento é bastante vago.

Então, as duas afirmações acima estão corretas em algum sentido ou são apenas conceitos errados sobre linguagens puramente funcionais? Se eles são conceitos errados, como eles surgiram? Você poderia escrever um trecho de código (possivelmente pequeno) que ilustra a maneira idiomática de Haskell de (1) implementar efeitos colaterais e (2) implementar uma computação com estado?

Giorgio
fonte
7
Acho que a maior parte disso depende do que você define como uma linguagem funcional 'pura'.
jk.
@jk: Para evitar o problema de definir linguagens funcionais 'puras', assuma pureza no sentido Haskell (que está bem definido). Sob quais condições uma linguagem funcional pode ser considerada 'pura' pode ser o tópico de uma pergunta futura.
Giorgio
Ambas as respostas contêm muitas idéias esclarecedoras e foi difícil para mim escolher qual delas aceitar. Decidi aceitar a resposta do sepp2k por causa dos exemplos adicionais de pseudo-código.
Giorgio

Respostas:

26

Para os fins desta resposta, defino "linguagem puramente funcional" como uma linguagem funcional na qual as funções são referencialmente transparentes, ou seja, chamar a mesma função várias vezes com os mesmos argumentos sempre produzirá os mesmos resultados. Essa é, acredito, a definição usual de uma linguagem puramente funcional.

Linguagens de programação funcionais puras não permitem efeitos colaterais (e, portanto, são pouco úteis na prática, porque qualquer programa útil tem efeitos colaterais, por exemplo, quando interage com o mundo externo).

A maneira mais fácil de obter transparência referencial seria de fato proibir efeitos colaterais e, de fato, existem idiomas nos quais esse é o caso (principalmente os específicos de domínio). No entanto, certamente não é a única maneira e as linguagens puramente funcionais de propósito geral (Haskell, Clean, ...) permitem efeito colateral.

Dizer também que uma linguagem de programação sem efeitos colaterais é pouco usada na prática não é realmente justo, eu acho - certamente não para linguagens específicas de domínio, mas mesmo para linguagens de uso geral, eu imagino que uma linguagem possa ser bastante útil sem fornecer efeitos colaterais . Talvez não para aplicativos de console, mas acho que aplicativos de GUI podem ser bem implementados sem efeitos colaterais, digamos, no paradigma reativo funcional.

Em relação ao ponto 1, você pode interagir com o ambiente em linguagens puramente funcionais, mas é necessário marcar explicitamente o código (funções) que as apresenta (por exemplo, no Haskell por meio de tipos monádicos).

Isso é um pouco mais do que simplificá-lo. Apenas ter um sistema em que as funções de efeito colateral precisam ser marcadas como tal (semelhante à const-correção no C ++, mas com efeitos colaterais gerais) não é suficiente para garantir a transparência referencial. Você precisa garantir que um programa nunca possa chamar uma função várias vezes com os mesmos argumentos e obter resultados diferentes. Você poderia fazer isso criando coisas comoreadLineseja algo que não seja uma função (é o que Haskell faz com a mônada de IO) ou você pode tornar impossível chamar funções de efeito colateral várias vezes com o mesmo argumento (é o que o Clean faz). No último caso, o compilador garantirá que toda vez que você chamar uma função de efeito colateral, faça isso com um argumento novo e rejeite qualquer programa em que você passe o mesmo argumento para uma função de efeito colateral duas vezes.

Linguagens de programação funcionais puras não permitem escrever um programa que mantenha estado (o que torna a programação muito incômoda porque em muitos aplicativos você precisa de estado).

Novamente, uma linguagem puramente funcional pode muito bem impedir o estado mutável, mas certamente é possível ser puro e ainda ter um estado mutável, se você implementá-lo da mesma maneira que eu descrevi com os efeitos colaterais acima. O estado realmente mutável é apenas outra forma de efeitos colaterais.

Dito isto, as linguagens de programação funcional definitivamente desencorajam o estado mutável - especialmente os puros. E não acho que isso torne a programação estranha - muito pelo contrário. Às vezes (mas nem sempre), o estado mutável não pode ser evitado sem perder o desempenho ou a clareza (é por isso que idiomas como Haskell têm recursos para o estado mutável), mas na maioria das vezes pode.

Se eles são conceitos errados, como eles surgiram?

Eu acho que muitas pessoas simplesmente leem "uma função deve produzir o mesmo resultado quando chamada com os mesmos argumentos" e concluem que não é possível implementar algo como readLineou código que mantenha um estado mutável. Portanto, eles simplesmente não estão cientes dos "truques" que as linguagens puramente funcionais podem usar para introduzir essas coisas sem quebrar a transparência referencial.

Além disso, o estado mutável desencoraja fortemente as linguagens funcionais; portanto, não é um grande salto presumir que não seja permitido em todas as que sejam puramente funcionais.

Você poderia escrever um trecho de código (possivelmente pequeno) que ilustra a maneira idiomática de Haskell de (1) implementar efeitos colaterais e (2) implementar uma computação com estado?

Aqui está um aplicativo em Pseudo-Haskell que solicita um nome ao usuário e o cumprimenta. Pseudo-Haskell é uma linguagem que acabei de inventar, que possui o sistema de IO de Haskell, mas usa sintaxe mais convencional, nomes de função mais descritivos e sem donotação (pois isso apenas distrairia o modo como a mônada de IO funciona):

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

A pista aqui é que readLineé um valor do tipo IO<String>e composeMonadé uma função que aceita um argumento do tipo IO<T>(para algum tipo T) e outro argumento que é uma função que pega um argumento do tipo Te retorna um valor do tipo IO<U>(para algum tipo U). printé uma função que recebe uma string e retorna um valor do tipo IO<void>.

Um valor do tipo IO<A>é um valor que "codifica" uma determinada ação que produz um valor do tipo A. composeMonad(m, f)produz um novo IOvalor que codifica a ação de mseguida pela ação de f(x), onde xé o valor produzido executando a ação de m.

O estado mutável ficaria assim:

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) )

Aqui mutableVariableestá uma função que pega valor de qualquer tipo Te produz a MutableVariable<T>. A função getValuepega MutableVariablee retorna um IO<T>que produz seu valor atual. setValuepega a MutableVariable<T>e a Te retorna um IO<void>que define o valor. composeVoidMonadé o mesmo que composeMonadexceto que o primeiro argumento é IOaquele que não produz um valor sensível e o segundo argumento é outra mônada, não uma função que retorna uma mônada.

Em Haskell, há um pouco de açúcar sintático, que torna todo esse calvário menos doloroso, mas ainda é óbvio que o estado mutável é algo que a linguagem realmente não quer que você faça.

sepp2k
fonte
Ótima resposta, esclarecendo muitas idéias. A última linha do trecho de código deve usar o nome counter, ou seja increaseCounter(counter)?
Giorgio
@Giorgio Sim, deveria. Fixo.
sepp2k
1
@Giorgio Uma coisa que esqueci de mencionar explicitamente no meu post é que a ação de E / S retornada por mainserá a que realmente for executada. Além de retornar um IO de mainlá, não há maneira de executar IOações (sem usar funções terrivelmente ruins que têm unsafeem seu nome).
sepp2k
ESTÁ BEM. Scarfridge também mencionou IOvalores destrutivos . Não entendi se ele se refere à correspondência de padrões, ou seja, ao fato de que você pode desconstruir valores de um tipo de dados algébricos, mas não se pode usar a correspondência de padrões para fazer isso com IOvalores.
Giorgio
16

IMHO você está confuso porque há uma diferença entre uma linguagem pura e uma função pura . Vamos começar com a função. Uma função é pura se (sempre com a mesma entrada) retornar sempre o mesmo valor e não causar efeitos colaterais observáveis. Exemplos típicos são funções matemáticas como f (x) = x * x. Agora considere uma implementação desta função. Seria puro na maioria das línguas, mesmo naquelas que não são geralmente consideradas linguagens funcionais puras, por exemplo, ML. Mesmo um método Java ou C ++ com esse comportamento pode ser considerado puro.

Então, o que é uma linguagem pura? A rigor, pode-se esperar que uma linguagem pura não permita expressar funções que não são puras. Vamos chamar isso de definição idealista de uma linguagem pura. Esse comportamento é altamente desejável. Por quê? Bem, o bom de um programa que consiste apenas em funções puras é que você pode substituir o aplicativo de função por seu valor sem alterar o significado do programa. Isso facilita muito o raciocínio sobre os programas, pois depois que você souber o resultado, poderá esquecer a maneira como ele foi computado. A pureza também pode permitir que o compilador execute certas otimizações agressivas.

E daí se você precisar de algum estado interno? Você pode imitar o estado em uma linguagem pura simplesmente adicionando o estado antes da computação como parâmetro de entrada e o estado após a computação como parte do resultado. Em vez de Int -> Boolvocê obter algo parecido Int -> State -> (Bool, State). Você simplesmente torna a dependência explícita (que é considerada uma boa prática em qualquer paradigma de programação). BTW, existe uma mônada que é uma maneira particularmente elegante de combinar essas funções de imitação de estado em funções maiores de imitação de estado. Dessa forma, você definitivamente pode "manter o estado" em uma linguagem pura. Mas você precisa explicitar.

Então, isso significa que posso interagir com o exterior? Afinal, um programa útil deve interagir com o mundo real para ser útil. Mas entrada e saída obviamente não são puras. Escrever um byte específico em um arquivo específico pode ser bom pela primeira vez. Mas executar exatamente a mesma operação uma segunda vez pode retornar um erro porque o disco está cheio. Claramente, não existe linguagem pura (no significado idealista) que possa gravar em um arquivo.

Então, estamos diante de um dilema. Queremos principalmente funções puras, mas alguns efeitos colaterais são absolutamente necessários e esses não são puros. Agora, uma definição realista de uma linguagem pura seria que deve haver alguns meios para separar as partes puras das outras partes. O mecanismo deve garantir que nenhuma operação impura se infiltre nas partes puras.

Em Haskell, isso é feito com o tipo de E / S. Você não pode destruir um resultado de E / S (sem mecanismos não seguros). Portanto, você só pode processar resultados de E / S com funções definidas no próprio módulo de E / S. Felizmente, existem combinadores muito flexíveis que permitem obter um resultado de IO e processá-lo em uma função, desde que essa função retorne outro resultado de IO. Este elemento de combinação é chamado ligação (ou >>=) e tem o tipo IO a -> (a -> IO b) -> IO b. Se você generaliza esse conceito, chega à classe Mônada e o IO passa a ser uma instância dele.

scarfridge
fonte
4
Realmente não vejo como Haskell (ignorando qualquer função com unsafeseu nome) não atende à sua definição idealista. Não há funções impuras em Haskell (novamente ignorando unsafePerformIOe co.).
sepp2k
4
readFilee writeFilesempre retornará o mesmo IOvalor, dados os mesmos argumentos. Por exemplo, os dois trechos de código let x = writeFile "foo.txt" "bar" in x >> xe writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"farão a mesma coisa.
sepp2k
3
@AidanCully O que você quer dizer com "função IO"? Uma função que retorna um valor do tipo IO Something? Nesse caso, é perfeitamente possível chamar uma função de E / S duas vezes com o mesmo argumento: putStrLn "hello" >> putStrLn "hello"- aqui ambas chamam para putStrLnter o mesmo argumento. Claro que isso não é um problema, porque, como eu disse anteriormente, as duas chamadas resultarão no mesmo valor de IO.
sepp2k
3
@scarfridge A avaliação writeFile "foo.txt" "bar"não pode causar um erro porque a avaliação da chamada de função não executa a ação. Se você está dizendo que, no meu exemplo anterior, a versão com letapenas uma oportunidade de causar uma falha de IO, enquanto a versão sem letduas, você está errado. Ambas as versões têm duas oportunidades para uma falha de E / S. Como a letversão avalia a chamada writeFileapenas uma vez, enquanto a versão sem a letavalia duas vezes, você pode ver que não importa com que frequência a função é chamada. Isso só importa quantas vezes o resultado ...
sepp2k
6
@AidanCully O "mecanismo de mônada" não transmite parâmetros implícitos. A putStrLnfunção usa exatamente um argumento, que é do tipo String. Se você não acredita em mim, olhe para o seu tipo: String -> IO (). Certamente não aceita nenhum argumento do tipo IO- produz um valor desse tipo.
sepp2k