Efeitos colaterais quebrando a transparência referencial

11

A programação funcional no Scala explica o impacto de um efeito colateral na quebra da transparência referencial:

efeito colateral, o que implica alguma violação da transparência referencial.

Eu li parte do SICP , que discute o uso do "modelo de substituição" para avaliar um programa.

Como eu aproximadamente entender o modelo de substituição com transparência referencial (TR), você pode de-compor uma função em suas partes mais simples. Se a expressão for RT, você poderá descompor a expressão e sempre obter o mesmo resultado.

No entanto, como a citação acima afirma, o uso de efeitos colaterais pode / irá quebrar o modelo de substituição.

Exemplo:

val x = foo(50) + bar(10)

Se fooe bar não tiver efeitos colaterais, a execução de qualquer função sempre retornará o mesmo resultado para x. Mas, se tiverem efeitos colaterais, eles alterarão uma variável que interrompe / lança uma chave inglesa no modelo de substituição.

Sinto-me à vontade com essa explicação, mas não a entendo totalmente.

Por favor, corrija-me e preencha todos os buracos com relação aos efeitos colaterais que causam a RT, discutindo os efeitos no modelo de substituição também.

Kevin Meredith
fonte

Respostas:

20

Vamos começar com uma definição para transparência referencial :

Diz-se que uma expressão é referencialmente transparente se puder ser substituída por seu valor sem alterar o comportamento de um programa (em outras palavras, produzindo um programa que tenha os mesmos efeitos e saída na mesma entrada).

O que isso significa é que (por exemplo) você pode substituir 2 + 5 por 7 em qualquer parte do programa, e o programa ainda deve funcionar. Esse processo é chamado de substituição. A substituição é válida se, e somente se, 2 + 5 puder ser substituído por 7 sem afetar nenhuma outra parte do programa .

Digamos que eu tenha uma classe chamada Baz, com as funções Fooe Barnela. Por uma questão de simplicidade, basta dizer isso Fooe Barambos retornam o valor que é passado. Assim Foo(2) + Bar(5) == 7, como seria de esperar. A transparência referencial garante que você possa substituir a expressão Foo(2) + Bar(5)pela expressão 7em qualquer lugar do seu programa, e o programa ainda funcionará de forma idêntica.

Mas e se Fooretornasse o valor passado, mas Barretornasse o valor passado, mais o último valor fornecido Foo? Isso é fácil o suficiente, se você armazenar o valor de Foouma variável local dentro da Bazclasse. Bem, se o valor inicial dessa variável local for 0, a expressão Foo(2) + Bar(5)retornará o valor esperado da 7primeira vez que você a chamar, mas retornará 9na segunda vez que você a chamar.

Isso viola a transparência referencial de duas maneiras. Primeiro, não é possível contar com a barra para retornar a mesma expressão sempre que for chamada. Segundo, ocorreu um efeito colateral, ou seja, chamar Foo influencia o valor de retorno de Bar. Como você não pode mais garantir que Foo(2) + Bar(5)será igual a 7, não poderá mais substituí-lo.

É isso que Transparência Referencial significa na prática; uma função referencialmente transparente aceita algum valor e retorna um valor correspondente, sem afetar outro código em outro lugar do programa, e sempre retorna a mesma saída, com a mesma entrada.

Robert Harvey
fonte
5
Então, a quebra RTdesabilita o uso do substitution model.O grande problema de não poder usar o substitution modelé o poder de usá-lo para raciocinar sobre um programa?
Kevin Meredith
Isso é exatamente correto.
Robert Harvey
1
+1 resposta maravilhosamente clara e compreensível. Obrigado.
Racheet
2
Além disso, se essas funções são transparentes ou "puras", a ordem em que elas realmente são executadas não é importante, não nos importamos se foo () ou bar () é executada primeiro e, em alguns casos, elas podem nunca avaliar se não são necessárias.
Zachary K
1
Ainda outra vantagem do RT é que expressões caras referencialmente transparentes podem ser armazenadas em cache (uma vez que avaliá-las uma ou duas vezes deve produzir exatamente o mesmo resultado).
Dcastro
3

Imagine que você está tentando construir uma parede e recebeu uma variedade de caixas em diferentes tamanhos e formas. Você precisa preencher um buraco específico em forma de L na parede; você deve procurar uma caixa em forma de L ou pode substituir duas caixas retas do tamanho apropriado?

No mundo funcional, a resposta é que qualquer uma das soluções funcionará. Ao construir seu mundo funcional, você nunca precisa abrir as caixas para ver o que está dentro.

No mundo imperativo, é perigoso construir sua parede sem inspecionar o conteúdo de cada caixa e compará-las com o conteúdo de todas as outras caixas:

  • Alguns contêm ímãs fortes e empurram outras caixas magnéticas para fora da parede se alinhadas incorretamente.
  • Alguns são muito quentes ou frios e reagirão mal se colocados em espaços adjacentes.

Acho que vou parar antes de desperdiçar seu tempo com metáforas mais improváveis, mas espero que o argumento seja acertado; tijolos funcionais não contêm surpresas ocultas e são totalmente previsíveis. Como você sempre pode usar blocos menores do tamanho e formato certos para substituir um maior e não há diferença entre duas caixas do mesmo tamanho e formato, você tem transparência referencial. Com tijolos imperativos, não basta ter algo do tamanho e formato certos - você precisa saber como o tijolo foi construído. Não é referencialmente transparente.

Em uma linguagem funcional pura, tudo que você precisa ver é a assinatura de uma função para saber o que ela faz. Claro, você pode querer olhar para dentro para ver o desempenho, mas não precisa olhar.

Em uma linguagem imperativa, você nunca sabe o que as surpresas podem esconder por dentro.

itsbruce
fonte
"Em uma linguagem funcional pura, tudo que você precisa ver é a assinatura de uma função para saber o que ela faz." - Isso geralmente não é verdade. Sim, sob a suposição de polimorfismo paramétrico, podemos concluir que uma função do tipo (a, b) -> apode ser apenas a fstfunção e que uma função do tipo a -> apode ser apenas a identityfunção, mas você não pode necessariamente dizer algo sobre uma função do tipo (a, a) -> a, por exemplo.
Jörg W Mittag
2

Como eu entendo o modelo de substituição (com transparência referencial (RT)), você pode decompor uma função em suas partes mais simples. Se a expressão for RT, você poderá descompor a expressão e sempre obter o mesmo resultado.

Sim, a intuição está certa. Aqui estão algumas dicas para ser mais preciso:

Como você disse, qualquer expressão de RT deve ter um single"resultado". Ou seja, dada uma factorial(5)expressão no programa, ele sempre deve produzir o mesmo "resultado". Portanto, se um determinado factorial(5)está no programa e gera 120, deve sempre gerar 120, independentemente de qual "ordem de etapas" é expandida / calculada - independentemente do tempo .

Exemplo: a factorialfunção

def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)

Existem algumas considerações com esta explicação.

Antes de tudo, lembre-se de que os diferentes modelos de avaliação (consulte pedido versus pedido normal) podem gerar "resultados" diferentes para a mesma expressão RT.

def first(y, z):
  return y

def second(x):
  return second(x)

first(2, second(3)) # result depends on eval. model

No código acima, firste secondsão referencialmente transparentes e, no entanto, a expressão no final gera "resultados" diferentes se avaliados na ordem normal e na ordem do aplicativo (no último, a expressão não é interrompida).

.... o que leva ao uso de "resultado" entre aspas. Como não é necessário que uma expressão seja interrompida, ela pode não produzir um valor. Então, usar "resultado" é meio embaçado. Pode-se dizer que uma expressão RT sempre produz o mesmo computationsem um modelo de avaliação.

Terceiro, pode ser necessário ver duas foo(50)aparecendo no programa em locais diferentes como expressões diferentes - cada uma produzindo seus próprios resultados que podem diferir entre si. Por exemplo, se a linguagem permitir escopo dinâmico, ambas as expressões, embora sejam lexicamente idênticas, serão diferentes. Em perl:

sub foo {
    my $x = shift;
    return $x + $y; # y is dynamic scope var
}

sub a {
    local $y = 10;
    return &foo(50); # expanded to 60
}

sub b {
    local $y = 20;
    return &foo(50); # expanded to 70
}

O escopo dinâmico confunde, porque facilita pensar que xé a única entrada para foo, quando, na realidade, é xe y. Uma maneira de ver a diferença é transformar o programa em um equivalente sem escopo dinâmico - ou seja, passando explicitamente os parâmetros, em vez de definir foo(x), definimos foo(x, y)e passamos yexplicitamente nos chamadores.

O ponto é que estamos sempre com uma functionmentalidade: dada uma certa entrada para uma expressão, recebemos um "resultado" correspondente. Se dermos a mesma entrada, devemos sempre esperar o mesmo "resultado".

Agora, e o código a seguir?

def foo():
   global y
   y = y + 1
   return y

y = 10
foo() # yields 11
foo() # yields 12

O fooprocedimento interrompe o RT porque há redefinições. Ou seja, definimos yem um ponto e, posteriormente, redefinimos o mesmo y . No exemplo perl acima, os ys são ligações diferentes, embora compartilhem o mesmo nome da letra "y". Aqui os ysão realmente os mesmos. É por isso que dizemos que (re) atribuição é uma meta operação: na verdade, você está alterando a definição do seu programa.

Grosso modo, as pessoas geralmente descrevem a diferença da seguinte maneira: em um ambiente sem efeitos colaterais, você tem um mapeamento input -> output. Em um cenário "imperativo", você tem input -> ouputno contexto de um stateque pode mudar com o tempo.

Agora, em vez de apenas substituir expressões por seus valores correspondentes, também é necessário aplicar transformações ao stateem cada operação que requer (e, é claro, as expressões podem consultar o mesmo statepara realizar cálculos).

Portanto, se em um programa livre de efeitos colaterais tudo o que precisamos saber para calcular uma expressão é sua entrada individual, em um programa imperativo, precisamos conhecer as entradas e todo o estado, para cada etapa computacional. O raciocínio é o primeiro a sofrer um grande golpe (agora, para depurar um procedimento problemático, você precisa da entrada e do core dump). Certos truques são impraticáveis, como memorização. Mas também simultaneidade e paralelismo se tornam muito mais desafiadores.

Thiago Silva
fonte
1
É bom que você mencione memorização. Isso pode ser usado como um exemplo de estado interno que não é visível do lado de fora: uma função que usa memoização ainda é referencialmente transparente, mesmo que internamente use estado e mutação.
Giorgio