Qual é o raciocínio por trás de tornar o não-determinismo uma característica de Haskell?

8

Sabemos que no Prolog - predicados não determinísticos são um recurso usado para diminuir problemas combinatórios .

Em Haskell, vemos um comportamento não determinístico semelhante ao Prolog na lista Mônada .

Em Haskell, também vemos o não determinismo na escolha da ordem de avaliação thunk :

Mas não há nada que diga ao GHC qual desses thunks deve ser avaliado primeiro e, portanto, o GHC tem total liberdade para escolher o que avaliar primeiro.

Isso é fascinante - e um tanto libertador. Estou me perguntando (além de lidar com problemas lógicos como oito rainhas ) que princípio isso serve. Havia alguma grande ideia ou grande problema que eles estavam tentando resolver com o não-determinismo?

Minha pergunta é: Qual é o raciocínio por trás de tornar o não determinismo uma característica de Haskell?

Hawkeye
fonte
Como a avaliação de thunk de Haskell é referencialmente transparente (pura), a ordem não afeta o resultado. Não é uma programação não determinística no sentido de Prolog, onde um dos muitos resultados possíveis é escolhido.
Jack
Claro, mas quando você está projetando um compilador, certamente é mais trabalhoso adicionar o não-determinismo? O que o motiva a colocar o trabalho extra?
hawkeye
2
O padrão não define a ordem da avaliação. O padrão não força o compilador a escolher uma ordem específica. Isso não significa que o compilador esteja de alguma forma escolhendo aleatoriamente ou algo assim. O compilador escolhe a ordem deterministicamente, de acordo com suas próprias regras. Apenas as regras não são definidas pelo padrão e são deixadas como um detalhe de implementação do compilador.
Fyodor Soikin
Obrigado @FyodorSoikin - isso é útil. Não estou preocupado se é o compilador ou o padrão - apenas a intenção por trás da escolha do design. Por que fazê-lo? O que você ganha com isso?
hawkeye
1
Ao não incluir uma ordem específica de avaliação no padrão, você obtém a liberdade para o compilador alterá-lo da maneira que achar melhor, permitindo otimizações. Isso é coisa bastante comum. Até os microprocessadores interferem na ordem das instruções, se puderem provar que isso não afeta o resultado.
Fyodor Soikin

Respostas:

19

Embora seja verdade que ambos os aspectos citados nas perguntas aparecem como formas de não-determinismo, eles são realmente muito diferentes tanto na forma como trabalham quanto em seus objetivos. Portanto, qualquer resposta deve ser necessariamente dividida em duas partes.

Ordem de avaliação

Haskell não exige nenhuma ordem de execução específica na avaliação de thunks, essencialmente por dois motivos.

  1. Antes de tudo, Haskell é uma linguagem puramente funcional, então você garante uma transparência referencial (se não mexer com a unsafePerformIO& co.). Isso significa que a avaliação de qualquer expressão, por exemplo f x , resultará no mesmo resultado, não importa quantas vezes ela seja avaliada e não importa em que parte do programa ela será avaliada (assumindo fe xvinculando os mesmos valores nos escopos considerados, de curso). Portanto, ordenar qualquer ordem específica de execução não teria nenhum objetivo , porque sua alteração não produziria efeitos observáveis ​​no resultado do programa. Nesse sentido, essa não é realmente uma forma de não-determinismo, pelo menos nenhuma forma de observação primeiro, uma vez que as diferentes execuções possíveis do programa são todas semanticamente equivalentes.

Entretanto, alterar a ordem de execução pode afetar o desempenho do programa, e deixar ao compilador a liberdade de manipular a ordem à sua vontade é fundamental para obter o desempenho incrível que um compilador como o GHC pode obter ao compilar um desempenho tão alto. linguagem de nível. Como exemplo, pense em uma transformação clássica de fusão de fluxo:

map f . map g = map (f.g)

Essa igualdade significa simplesmente que aplicar duas funções a uma lista com mapo mesmo resultado do que aplicar uma vez a composição das duas funções. Isso só é verdade por causa da transparência referencial e é um tipo de transformação que o compilador sempre podeaplicar, não importa o quê. Se alterar a ordem de execução das três funções tivesse efeitos no resultado da expressão, isso não seria possível. Por outro lado, compilá-lo na segunda forma, em vez da primeira, pode ter um enorme impacto no desempenho, porque evita a construção de uma lista temporária e a percorre apenas uma vez. O fato de o GHC poder aplicar automaticamente essa transformação é uma conseqüência direta da transparência referencial e da ordem de execução não fixa e é um dos aspectos principais do excelente desempenho que Haskell pode alcançar.

  1. Haskell é uma linguagem preguiçosa . Isso significa que qualquer expressão específica não precisa ser avaliada, a menos que seu resultado seja realmente necessário, e isso também poderia nunca ser. A preguiça é um recurso às vezes debatido e algumas outras linguagens funcionais evitam ou limitam sua aceitação, mas no contexto de Haskell, é um recurso essencial na maneira como a linguagem é usada e projetada. A preguiça é outra ferramenta poderosa nas mãos do otimizador do compilador e, o mais importante, permite que o código seja composto facilmente.

Para entender o que quero dizer com facilidade de composição, considere um exemplo quando você tem uma função producer :: Int -> [Int]que executa alguma tarefa complexa para calcular uma lista de algum tipo de dados a partir de um argumento de entrada e consumer :: [Int] -> Intque é outra função complexa que calcula um resultado de uma lista de dados de entrada. Você as escreveu separadamente, as testou, as otimizou com muito cuidado e as usou isoladamente em diferentes projetos. Agora, no próximo projeto, acontece que você precisa chamar consumero resultado deproducer. Em uma linguagem não preguiçosa, isso pode não ser o ideal, pois pode ser o caso de a tarefa combinada ser implementada com mais eficiência sem criar uma estrutura de lista temporária. Para obter uma implementação otimizada, você teria que reimplementar a tarefa combinada do zero, testá-la novamente e otimizar novamente.

Em haskell, isso não é necessário, e chamar a composição das duas funções consumer . produceré perfeitamente adequado. O motivo é que o programa não precisa produzir o resultado inteiro producerantes de entregá-lo consumer. De fato, assim que consumerprecisar o primeiro elemento de sua lista de entrada, o código correspondente producerserá executado o quanto for necessário para produzi-lo, e não mais. Quando o segundo elemento for necessário, ele será calculado. Se algum elemento não for necessário consumer, ele não será computado, economizando efetivamente cálculos inúteis. A execução consumereproducerserá efetivamente intercalado, não apenas evitando o uso de memória da estrutura da lista intermediária, mas também possivelmente evitando cálculos inúteis, e a execução provavelmente será semelhante à versão combinada escrita à mão que você teria que escrever para si mesmo. Isto é o que eu quis dizer com composição . Você tinha dois trechos de código bem testados e com bom desempenho e poderia compor-los, obtendo gratuitamente um trecho de código bem testado e com bom desempenho.

Mônadas não determinísticas

O uso de comportamento não determinístico fornecido pelas mônadas da Lista e similares é totalmente diferente. Aqui, o ponto não é o de fornecer ao compilador meios de otimizar seu programa, mas expressar de forma clara e concisa os cálculos que são inerentemente não determinísticos.

Um exemplo do que quero dizer é fornecido pela interface da Data.Boolean.SatSolverbiblioteca. Ele fornece um solucionador DPLL SAT muito simples implementado em Haskell. Como você deve saber, resolver o problema SAT envolve encontrar uma atribuição de variáveis ​​booleanas que satisfaçam uma fórmula booleana. Entretanto, pode haver mais de uma dessas atribuições, e pode ser necessário encontrá-las ou iterar sobre todas elas, dependendo do aplicativo. Portanto, muitas bibliotecas terão duas funções diferentes, como getSolutione getAllSolutions. Em vez disso, esta biblioteca possui apenas uma função solve, com o seguinte tipo:

solve :: MonadPlus m => SatSolver -> m SatSolver

Aqui, o resultado é um SatSolvervalor agrupado dentro de uma mônada de tipo não especificado, que, no entanto, é restrito a implementar a MonadPlusclasse de tipo. Essa classe de tipo é aquela que representa o tipo de não determinismo fornecido pela mônada da lista e, de fato, as listas são instâncias. Todas as funções que operam em SatSolvervalores retornam seus resultados agrupados em uma MonadPlusinstância. Então, suponha que você tenha a fórmula p || !qe deseje resolvê-la restringindo os resultados que são qverdadeiros; o uso é o seguinte (as variáveis ​​são numeradas em vez de serem identificadas pelo nome):

expr = Var 1 :||: Not (Var 2)

task :: MonadPlus m => m SatSolver
task = do
  pure newSatSolver
  assertTrue expr
  assertTrue (Var 2)

Observe como a instância de mônada e a notação de máscara mascaram todos os detalhes de baixo nível de como as funções gerenciam a SatSolverestrutura de dados e nos permitem expressar claramente nossa intenção.

Agora, se você deseja obter todos os resultados, basta usar solveem um contexto em que o resultado deve ser uma lista. A seguir, imprimiremos todos os resultados na tela (assumindo uma Showinstância para SatSolver, que não existe, mas perdoe-me este ponto).

main = sequence . map print . solve task

No entanto, as listas não são as únicas instâncias de MonadPlus. Maybeé outra instância. Portanto, se você precisar apenas de uma solução, não importa qual, poderá usar solvecomo se retornasse um Maybe SatSolvervalor:

main = case solve task of 
  Nothing -> putStrLn "No solution"
  Just result -> print result

Agora, suponha que você tem duas tarefas de modo integrado, taske task2, e você deseja obter uma solução para qualquer um. Mais uma vez, tudo se reúne para que possamos compor nossos blocos de construção pré-existentes:

combinedTask = task <|> task2

onde <|>é uma operação binária fornecida pela Alternativeclasse de tipo, que é uma super classe de MonadPlus. Novamente, vamos expressar claramente nossa intenção, reutilizando o código sem alterações. O não-determinismo é claramente expresso em código, não oculto sob os detalhes de como o não-determinismo é realmente implementado. Sugiro que você dê uma olhada nos combinadores criados sobre a Alternativeclasse type para obter mais exemplos.

As mônadas não determinísticas, como listas, não são apenas uma maneira de expressar bons exercícios, mas oferecem uma maneira de projetar códigos elegantes e reutilizáveis ​​que expressam claramente a intenção na implementação de tarefas que são inerentemente não determinísticas.

gigabytes
fonte
Não acho que sua taskimplementação esteja correta. assertTruerequer dois parâmetros e você está apenas dando um. Você ainda precisa de um canal um pouco mais explícito do SatSolvervalor entre as funções se quiser usar a donotação.
4castle
Ah! A primeira versão que eu escrevia usava uma cadeia de >> =, para que você pudesse (e tivesse que) evitar nomear o argumento. Este parece um caso em que a notação é mais detalhada. Sinta-se à vontade para editar ou farei isso assim que voltar ao escritório.
gigabytes