Por que é mais fácil argumentar sobre linguagens de programação e programas que não têm efeitos colaterais?

8

Eu li "O porquê de Y" de Richard P. Gabriel . É um artigo de fácil leitura sobre o combinador Y, o que é bastante raro. O artigo começa com a definição recursiva da função fatorial:

(letrec ((f (lambda (n)
              (if (< n 2) 1 (* n (f (- n 1)))))))
  (f 10))

E explica que letrecpode ser definido com um efeito colateral:

(let ((f #f))
  (set! f (lambda (n)
            (if (< n 2) 1 (* n (f (- n 1))))))
  (f 10))

E o restante do artigo descreve que também é possível definir letreccom o combinador Y:

(define (Y f)
  (let ((g (lambda (h)
             (lambda (x)
               ((f (h h)) x)))))
    (g g)))

(let ((f (Y (lambda (fact)
              (lambda (n)
                (if (< n 2) 1 (* n (fact (- n 1)))))))))
  (f 10))

Obviamente, isso é muito mais complicado do que a versão com o efeito colateral. A razão pela qual é benéfico preferir o combinador Y sobre o efeito colateral é dada apenas pela declaração:

É mais fácil argumentar sobre linguagens de programação e programas que não têm efeitos colaterais.

Isso não é explicado mais. Eu tento encontrar uma explicação.

ceving
fonte
Essa linha "mais fácil de raciocinar" é pura propaganda. É sempre dado como um artigo de fé - nenhuma evidência é necessária nem oferecida - e, quando analisada criticamente, nem passa no teste da risada. Como você observou, é trivialmente óbvio que a versão do Y Combinator é duas vezes mais complicada e, portanto, mais difícil de entender e raciocinar!
Mason Wheeler
5
@MasonWheeler Passar um objeto mutável para vários métodos dificulta a identificação de onde está sendo usado apenas como entrada e onde está sendo modificado no local. A alternativa funcional - retornando uma nova cópia do objeto - deixa claro. Não vou dizer que puro é sempre melhor, mas é difícil afirmar que grandes gráficos de objetos mutáveis ​​são fáceis de raciocinar. Há muito contexto invisível envolvido.
Doval
@Doval Como é "esclarecido" quando agora você tem várias cópias de seus objetos por aí, algumas obsoletas, outras canônicas e agora você precisa manter isso em ordem? Isso soa ainda mais confuso! (Ou, alternativamente, você deve garantir que não são nenhuma referência a quaisquer cópias secundárias, o que é uma tarefa exatamente equivalente ao gerenciamento de memória manual, que FP encontrado soooooo confusa e difícil de raciocinar sobre que inventou a coleta de lixo, a fim de evitar a necessidade )
Mason Wheeler
2
@MasonWheeler Mesmo quando os dados devem mudar, você quer controlar quem os está mudando. Você deseja passá-lo para um método que não deve modificá-lo, mas alguém pode errar e introduzir um bug que acaba mutando os dados de qualquer maneira. Então você acaba fazendo "cópias defensivas" (que é realmente uma recomendação no livro Java Efetivo!) E fazendo mais trabalho / gerando mais lixo do que usando uma estrutura de dados imutável desde o início. O fato de os dados mudarem nunca atrapalhou ninguém usando seqüências imutáveis ​​ou tipos numéricos.
Doval
2
Os idiomas FP do @MasonWheeler não geram muito lixo, caso contrário, seriam inúteis. Não é assim que eles funcionam "nos bastidores". O "mais fácil de raciocinar" geralmente se refere ao raciocínio equacional, que não é motivo de riso. O raciocínio equacional pode ser feito em muitos paradigmas, com sucesso variável, mas em idiomas FP é geralmente mais fácil, e isso é uma grande vitória (embora à custa de outras coisas; tudo é uma troca na vida).
Andres F.

Respostas:

13

Obviamente, você pode encontrar exemplos de funções puras incrivelmente difíceis de ler que executam os mesmos cálculos que funções com efeitos colaterais muito mais fáceis de ler. Especialmente quando você usa uma transformação mecânica como um combinador Y para chegar a uma solução. Não é isso que significa "mais fácil de raciocinar".

A razão pela qual é mais fácil argumentar sobre funções sem efeitos colaterais é que você só precisa se preocupar com as entradas e saídas. Com os efeitos colaterais, você também precisa se preocupar com quantas vezes as funções são chamadas, em que ordem elas são chamadas, quais dados são criados dentro da função, quais dados são compartilhados e quais dados são copiados. E todas essas informações para quaisquer funções que possam ser chamadas internas para a função que você está chamando, e recursivamente internas para essas funções, e assim por diante.

Esse efeito é muito mais fácil de ver no código de produção com várias camadas do que nas funções de exemplo de brinquedo. Principalmente significa que você pode confiar muito mais na assinatura de tipo de uma função. Você realmente percebe a carga dos efeitos colaterais se você faz uma programação funcional pura por um tempo e depois volta a ela.

Karl Bielefeldt
fonte
10

Uma propriedade interessante de linguagens sem efeitos colaterais é que a introdução de paralelismo, simultaneidade ou assincronia não pode alterar o significado do programa. Pode torná-lo mais rápido. Ou pode torná-lo mais lento. Mas não pode estar errado.

Isso torna trivial paralelizar programas automaticamente. Tão trivial, de fato, que você geralmente acaba com muito paralelismo! A equipe do GHC experimentou a paralelização automática. Eles descobriram que mesmo programas simples poderiam ser decompostos em centenas, até milhares de threads. A sobrecarga de todos esses threads sobrecarregará qualquer aceleração potencial em várias ordens de magnitude.

Portanto, para a paralelização automática de programas funcionais, o problema passa a ser "como você agrupa pequenas operações atômicas em tamanhos úteis de partes paralelas", em oposição a programas impuros, onde o problema é "como você divide grandes operações monolíticas em úteis tamanhos de peças paralelas ". O bom disso é que o primeiro pode ser feito heuristicamente (lembre-se: se você errar, a pior coisa que pode acontecer é que o programa seja um pouco mais lento do que poderia ser), enquanto o segundo é equivalente a resolver o problema Problema (no caso geral), e se você errar, seu programa simplesmente trava (se você tiver sorte!) Ou retorna resultados sutilmente errados (no pior caso).

Jörg W Mittag
fonte
ele também poderia impasse ou livelock, não que qualquer um é melhor ...
Deduplicator
6

Os idiomas com efeitos colaterais empregam análise de alias para verificar se um local de memória pode precisar ser recarregado após uma chamada de função. Quão conservadora é essa análise depende da linguagem.

Para C, isso deve ser bastante conservador, pois o idioma não é do tipo seguro.

Para Java e C #, eles não precisam ser tão conservadores por causa de sua maior segurança de tipo.

Ser excessivamente conservador evita otimizações de carga.

Essa análise seria desnecessária (ou trivial, dependendo de como você a vê) em um idioma sem efeitos colaterais.

Erik Eidt
fonte
Note-se que aliasing só é possível com as duas variáveis mutáveis e referências. Uma linguagem com apenas um ou o outro não tem esse problema
gardenhead
4

Sempre há otimizações para tirar proveito de quaisquer suposições que você der. Reordenar as operações vem à mente.

Um exemplo divertido que vem à mente realmente aparece em algumas linguagens assembly mais antigas. Em particular, o MIPS tinha uma regra de que a instrução após um salto condicional foi executada, independentemente de qual ramificação foi executada. Se você não quis isso, você colocou um NOP após o salto. Isso foi feito devido à maneira como o pipeline do MIPS foi estruturado. Havia uma paralisação natural de 1 ciclo incorporada na execução do salto condicional; portanto, você também pode fazer algo útil com esse ciclo!

Os compiladores geralmente procuram uma operação que precisa ser executada nos dois ramos e a deslizam para esse slot, para obter um pouco mais de desempenho. No entanto, se um compilador não puder fazer isso, mas puder mostrar que não houve efeitos colaterais na operação, o compilador poderá oportunamente colocá-lo nesse local. Assim, em um caminho, o código executaria uma instrução mais rapidamente. No outro caminho, nenhum dano causado.

Cort Ammon
fonte
1

"letrec pode ser definido com um efeito colateral ..." Não vejo efeito colateral em sua definição. Sim, ele usa set!uma maneira típica de produzir efeitos colaterais no Scheme, mas nesse caso não há efeito colateral - por fser puramente local, não pode ser referenciado por nenhuma função que não seja localmente. Portanto, não é um efeito colateral visto em qualquer escopo externo. O que esse código faz é desviar de uma limitação no escopo do Scheme que, por padrão, não permite que uma definição lambda se refira a si mesma.

Algumas outras linguagens têm declarações em que uma expressão usada para produzir o valor de uma constante pode se referir à própria constante. Nesse idioma, a definição equivalente exata pode ser usada, mas claramente isso não produz um efeito colateral, pois apenas uma constante é usada. Veja, por exemplo, este programa Haskell equivalente:

let f = \ n -> if n < 2 
                 then 1 
                 else n*(f (n-1)) 
        in (f 5)

(que é avaliado em 120).

Isso claramente não tem efeitos colaterais (como para que uma função no Haskell tenha um efeito colateral, ela deve retornar seu resultado envolto em uma Mônada, mas o tipo retornado aqui é um tipo numérico simples), mas é um código estruturalmente idêntico ao código que você cita.

Periata Breatta
fonte
Em geral, é um efeito colateral, porque letpoderia retornar a função local.
ceving 15/12/16
2
@ceving - mesmo assim, não é um efeito colateral, porque a modificação do local de armazenamento é limitada no tempo em que pode ocorrer antes que qualquer outro código possa lê-lo . Para que um efeito colateral seja real, deve ser possível que algum agente externo note que isso aconteceu; neste caso, não há como isso acontecer.
Periata Breatta
0

Isso não é explicado mais. Eu tento encontrar uma explicação.

É algo que é inerente a muitos de nós que depuramos grandes bases de código, mas é preciso lidar com uma escala grande o suficiente no nível superior por um tempo suficiente para apreciá-lo. É como entender a importância de estar em posição no Poker. Inicialmente, não parece uma vantagem tão útil durar no final de cada turno até que você grave um milhão de mãos e perceba que ganhou muito mais dinheiro em posição do que fora.

Dito isto, discordo da ideia de que uma alteração em uma variável local é um efeito colateral. Do meu ponto de vista, uma função não causa efeitos colaterais se não modificar nada fora do seu escopo, que qualquer coisa que toque e mexa não afetará nada abaixo da pilha de chamadas ou qualquer memória ou recurso que a função não tenha adquirido por si mesma .

Em geral, a coisa mais difícil de se raciocinar em uma base de código complexa e de larga escala que não possui o procedimento de teste mais perfeito possível é o gerenciamento de estado persistente, como todas as alterações em objetos granulares no mundo dos videogames enquanto você percorre dezenas de milhares de funções, enquanto tentavam restringir-se a uma lista interminável de suspeitos, que realmente causou a violação de uma invariante em todo o sistema ("isso nunca deveria acontecer, quem o fez?"). Se nada for alterado fora de uma função, isso não poderá causar um mau funcionamento central.

Claro que isso não é possível em todos os casos. Qualquer aplicativo que atualize um banco de dados armazenado em uma máquina diferente é, por natureza, projetado para causar efeitos colaterais, bem como qualquer aplicativo projetado para carregar e gravar arquivos. Mas há muito mais que podemos fazer sem efeitos colaterais em muitas funções e muitos programas se, por exemplo, tivermos uma rica biblioteca de estruturas de dados imutáveis ​​e adotamos ainda mais essa mentalidade.

Curiosamente, John Carmack saltou no LISP e na onda da imutabilidade, apesar de ter começado nos dias da codificação C mais micro-sintonizada. Eu me vi fazendo uma coisa semelhante, embora ainda use muito C. Essa é a natureza dos pragmáticos, acho, que passaram anos depurando e tentando raciocinar sobre sistemas complexos e de larga escala como um todo, de um nível superior. Mesmo os que são surpreendentemente robustos e desprovidos de uma grande quantidade de bugs ainda podem deixá-lo com uma sensação desconfortável de que algo de errado está à espreita ao virar da esquina se houver muito estado persistente complexo sendo modificado no gráfico interconectado mais complexo de chamadas de função entre os milhões de linhas de código. Mesmo que todas as interfaces sejam testadas com um teste de unidade e todas sejam aprovadas,

Na prática, muitas vezes acho que a programação funcional dificulta a compreensão de uma função. Ele ainda gira meu cérebro em reviravoltas e nós, especialmente com lógica recursiva complexa. Mas toda a sobrecarga intelectual associada à descoberta de algumas funções escritas em uma linguagem funcional é diminuída pelo sistema complexo, com estados persistentes sendo alterados em dezenas de milhares de funções, onde cada função que causa efeitos colaterais se soma ao total complexidade do raciocínio sobre a correção de todo o sistema.

Observe que você não precisa de uma linguagem funcional pura para fazer com que as funções evitem efeitos colaterais. Os estados locais alterados em uma função não contam como efeito colateral, como uma forvariável de contador de loop local para uma função não conta como efeito colateral. Até escrevo código C hoje em dia com o objetivo de evitar efeitos colaterais, quando possível, e criei uma biblioteca de estruturas de dados imutáveis ​​e seguras para threads que podem ser parcialmente modificadas enquanto o restante dos dados é copiado superficialmente, e isso me ajudou a muito a raciocinar sobre a correção do meu sistema. Discordo totalmente do autor nesse sentido. Pelo menos em C e C ++ em software de missão crítica, uma função pode estar documentando como sem efeitos colaterais se não tocar em nada que possa afetar o mundo fora da função.


fonte