3.1Atribuição e Estado Local

Normalmente vemos o mundo como povoado por objetos independentes, cada um dos quais tem um estado que muda ao longo do tempo. Diz-se que um objeto "tem estado" se seu comportamento é influenciado por sua história. Uma conta bancária, por exemplo, tem estado, pois a resposta à pergunta "Posso sacar $100?" depende do histórico de transações de depósito e saque. Podemos caracterizar o estado de um objeto por uma ou mais variáveis de estado, que mantêm informações suficientes sobre o histórico para determinar o comportamento atual do objeto. Em um sistema bancário simples, poderíamos caracterizar o estado de uma conta pelo saldo atual, em vez de lembrar todo o histórico de transações da conta.

Em um sistema composto por muitos objetos, os objetos raramente são completamente independentes. Cada um pode influenciar os estados dos outros por meio de interações, que servem para acoplar as variáveis de estado de um objeto às de outros objetos. De fato, a visão de que um sistema é composto por objetos separados é mais útil quando as variáveis de estado do sistema podem ser agrupadas em subsistemas fortemente acoplados que são apenas fracamente acoplados a outros subsistemas.

Essa visão de um sistema pode ser uma estrutura poderosa para organizar modelos computacionais do sistema. Para que tal modelo seja modular, ele deve ser decomposto em objetos computacionais que modelam os objetos reais do sistema. Cada objeto computacional deve ter suas próprias variáveis de estado locais que descrevem o estado do objeto real. Como os estados dos objetos no sistema que está sendo modelado mudam ao longo do tempo, as variáveis de estado dos objetos computacionais correspondentes também devem mudar. Se escolhermos modelar o fluxo do tempo no sistema pelo tempo decorrido no computador, então devemos ter uma maneira de construir objetos computacionais cujos comportamentos mudam à medida que nossos programas são executados. Em particular, se quisermos modelar variáveis de estado por nomes simbólicos comuns na linguagem de programação, então a linguagem deve fornecer um operador de atribuição para nos permitir alterar o valor associado a um nome.

3.1.1Variáveis de Estado Local

Para ilustrar o que queremos dizer com um objeto computacional com estado que varia no tempo, vamos modelar a situação de sacar dinheiro de uma conta bancária. Faremos isso usando um procedimento withdraw, que recebe como argumento um amount a ser sacado. Se houver dinheiro suficiente na conta para acomodar o saque, então withdraw deve retornar o saldo restante após o saque. Caso contrário, withdraw deve retornar a mensagem Fundos insuficientes. Por exemplo, se começarmos com $100 na conta, devemos obter a seguinte sequência de respostas usando withdraw:

(withdraw 25)
75

(withdraw 25)
50

(withdraw 60)
"Insufficient funds"

(withdraw 15)
35

Observe que a expressão (withdraw 25), avaliada duas vezes, produz valores diferentes. Este é um novo tipo de comportamento para um procedimento. Até agora, todos os nossos procedimentos podiam ser vistos como especificações para calcular funções matemáticas. Uma chamada a um procedimento calculava o valor da função aplicada aos argumentos fornecidos, e duas chamadas ao mesmo procedimento com os mesmos argumentos sempre produziam o mesmo resultado.129

Para implementar withdraw, podemos usar uma variável balance para indicar o saldo de dinheiro na conta e definir withdraw como um procedimento que acessa balance. O procedimento withdraw verifica se balance é pelo menos tão grande quanto o amount solicitado. Se for, withdraw decrementa balance por amount e retorna o novo valor de balance. Caso contrário, withdraw retorna a mensagem Fundos insuficientes. Aqui estão as definições de balance e withdraw:

(define balance 100)

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

Decrementar balance é realizado pela expressão

(set! balance (- balance amount))

Isso usa a forma especial set!, cuja sintaxe é

(set! ⟨name⟩ ⟨new-value⟩)

Aqui ⟨name⟩ é um símbolo e ⟨new-value⟩ é qualquer expressão. Set! altera ⟨name⟩ para que seu valor seja o resultado obtido ao avaliar ⟨new-value⟩. No caso em questão, estamos alterando balance para que seu novo valor seja o resultado de subtrair amount do valor anterior de balance.130

Withdraw também usa a forma especial begin para fazer com que duas expressões sejam avaliadas no caso em que o teste if é verdadeiro: primeiro decrementando balance e depois retornando o valor de balance. Em geral, avaliar a expressão

(begin ⟨exp₁⟩ ⟨exp₂⟩ … ⟨expₖ⟩)

faz com que as expressões e x p 1 até e x p k sejam avaliadas em sequência, e o valor da expressão final e x p k seja retornado como o valor de toda a forma begin.131

Embora withdraw funcione como desejado, a variável balance apresenta um problema. Conforme especificado acima, balance é um nome definido no ambiente global e é livremente acessível para ser examinado ou modificado por qualquer procedimento. Seria muito melhor se pudéssemos de alguma forma tornar balance interno a withdraw, de modo que withdraw fosse o único procedimento que pudesse acessar balance diretamente e qualquer outro procedimento pudesse acessar balance apenas indiretamente (por meio de chamadas a withdraw). Isso modelaria mais precisamente a noção de que balance é uma variável de estado local usada por withdraw para acompanhar o estado da conta.

Podemos tornar balance interno a withdraw reescrevendo a definição da seguinte forma:

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

O que fizemos aqui foi usar let para estabelecer um ambiente com uma variável local balance, vinculada ao valor inicial 100. Dentro desse ambiente local, usamos lambda para criar um procedimento que recebe amount como argumento e se comporta como nosso procedimento withdraw anterior. Este procedimento — retornado como resultado da avaliação da expressão let — é new-withdraw, que se comporta exatamente da mesma forma que withdraw, mas cuja variável balance não é acessível por qualquer outro procedimento.132

Combinar set! com variáveis locais é a técnica de programação geral que usaremos para construir objetos computacionais com estado local. Infelizmente, usar essa técnica levanta um problema sério: Quando introduzimos procedimentos, também introduzimos o modelo de substituição de avaliação (1.1.5) para fornecer uma interpretação do que significa a aplicação de um procedimento. Dissemos que aplicar um procedimento deve ser interpretado como avaliar o corpo do procedimento com os parâmetros formais substituídos por seus valores. O problema é que, assim que introduzimos atribuição em nossa linguagem, a substituição não é mais um modelo adequado para a aplicação de procedimentos. (Veremos por que isso acontece em 3.1.3.) Como consequência, tecnicamente não temos, neste momento, uma maneira de entender por que o procedimento new-withdraw se comporta como afirmado acima. Para realmente entender um procedimento como new-withdraw, precisaremos desenvolver um novo modelo de aplicação de procedimentos. Em 3.2, introduziremos tal modelo, junto com uma explicação de set! e variáveis locais. Primeiro, no entanto, examinaremos algumas variações sobre o tema estabelecido por new-withdraw.

O seguinte procedimento, make-withdraw, cria "processadores de saque". O parâmetro formal balance em make-withdraw especifica a quantidade inicial de dinheiro na conta.133

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

Make-withdraw pode ser usado da seguinte forma para criar dois objetos W1 e W2:

(define W1 (make-withdraw 100))
(define W2 (make-withdraw 100))

(W1 50)
50

(W2 70)
30

(W2 40)
"Insufficient funds"

(W1 40)
10

Observe que W1 e W2 são objetos completamente independentes, cada um com sua própria variável de estado local balance. Saques de um não afetam o outro.

Também podemos criar objetos que lidam com depósitos e saques, e assim podemos representar contas bancárias simples. Aqui está um procedimento que retorna um "objeto de conta bancária" com um saldo inicial especificado:

(define (make-account balance)
  (define (withdraw amount)
    (if (>= balance amount)
        (begin (set! balance (- balance amount))
               balance)
        "Insufficient funds"))
  (define (deposit amount)
    (set! balance (+ balance amount))
    balance)
  (define (dispatch m)
    (cond ((eq? m 'withdraw) withdraw)
          ((eq? m 'deposit) deposit)
          (else (error "Unknown request: MAKE-ACCOUNT" m))))
  dispatch)

Cada chamada a make-account configura um ambiente com uma variável de estado local balance. Dentro desse ambiente, make-account define os procedimentos deposit e withdraw que acessam balance e um procedimento adicional dispatch que recebe uma "mensagem" como entrada e retorna um dos dois procedimentos locais. O procedimento dispatch em si é retornado como o valor que representa o objeto de conta bancária. Este é precisamente o estilo de programação passagem de mensagens que vimos em 2.4.3, embora aqui estejamos usando-o em conjunto com a capacidade de modificar variáveis locais.

Make-account pode ser usado da seguinte forma:

(define acc (make-account 100))

((acc 'withdraw) 50)
50

((acc 'withdraw) 60)
"Insufficient funds"

((acc 'deposit) 40)
90

((acc 'withdraw) 60)
30

Cada chamada a acc retorna o procedimento deposit ou withdraw definido localmente, que é então aplicado ao amount especificado. Como foi o caso com make-withdraw, outra chamada a make-account

(define acc2 (make-account 100))

produzirá um objeto de conta completamente separado, que mantém seu próprio balance local.

Exercício 3.1: Um acumulador é um procedimento que é chamado repetidamente com um único argumento numérico e acumula seus argumentos em uma soma. Cada vez que é chamado, ele retorna a soma acumulada atualmente. Escreva um procedimento make-accumulator que gera acumuladores, cada um mantendo uma soma independente. A entrada para make-accumulator deve especificar o valor inicial da soma; por exemplo

(define A (make-accumulator 5))

(A 10)
15

(A 10)
25

Exercício 3.2: Em aplicações de teste de software, é útil poder contar o número de vezes que um determinado procedimento é chamado durante o curso de uma computação. Escreva um procedimento make-monitored que recebe como entrada um procedimento, f, que por sua vez recebe uma entrada. O resultado retornado por make-monitored é um terceiro procedimento, digamos mf, que mantém um contador interno do número de vezes que foi chamado. Se a entrada para mf for o símbolo especial how-many-calls?, então mf retorna o valor do contador. Se a entrada for o símbolo especial reset-count, então mf redefine o contador para zero. Para qualquer outra entrada, mf retorna o resultado de chamar f nessa entrada e incrementa o contador. Por exemplo, poderíamos fazer uma versão monitorada do procedimento sqrt:

(define s (make-monitored sqrt))

(s 100)
10

(s 'how-many-calls?)
1

Exercício 3.3: Modifique o procedimento make-account para que ele crie contas protegidas por senha. Ou seja, make-account deve receber um símbolo como argumento adicional, como em

(define acc 
(make-account 100 'secret-password))

O objeto de conta resultante deve processar uma solicitação apenas se ela for acompanhada pela senha com a qual a conta foi criada, e deve retornar uma reclamação caso contrário:

((acc 'secret-password 'withdraw) 40)
60

((acc 'some-other-password 'deposit) 50)
"Incorrect password"

Exercício 3.4: Modifique o procedimento make-account do Exercício 3.3 adicionando outra variável de estado local para que, se uma conta for acessada mais de sete vezes consecutivas com uma senha incorreta, ela invoque o procedimento call-the-cops.

3.1.2Os Benefícios de Introduzir Atribuição

Como veremos, introduzir atribuição em nossa linguagem de programação nos leva a uma série de questões conceituais difíceis. No entanto, ver sistemas como coleções de objetos com estado local é uma técnica poderosa para manter um design modular. Como um exemplo simples, considere o design de um procedimento rand que, sempre que for chamado, retorna um inteiro escolhido aleatoriamente.

Não está claro o que significa "escolhido aleatoriamente". O que presumivelmente queremos é que chamadas sucessivas a rand produzam uma sequência de números que tenha propriedades estatísticas de distribuição uniforme. Não discutiremos métodos para gerar sequências adequadas aqui. Em vez disso, vamos assumir que temos um procedimento rand-update que tem a propriedade de que, se começarmos com um número dado x 1 e formarmos

x₂ = (rand-update x₁)
x₃ = (rand-update x₂)

então a sequência de valores x 1 , x 2 , x 3 , … terá as propriedades estatísticas desejadas.134

Podemos implementar rand como um procedimento com uma variável de estado local x que é inicializada com algum valor fixo random-init. Cada chamada a rand calcula rand-update do valor atual de x, retorna isso como o número aleatório e também armazena isso como o novo valor de x.

(define rand
(let ((x random-init))
  (lambda () (set! x (rand-update x)) x)))

Claro, poderíamos gerar a mesma sequência de números aleatórios sem usar atribuição simplesmente chamando rand-update diretamente. No entanto, isso significaria que qualquer parte do nosso programa que usasse números aleatórios teria que lembrar explicitamente o valor atual de x para ser passado como argumento para rand-update. Para perceber o quanto isso seria incômodo, considere usar números aleatórios para implementar uma técnica chamada simulação de Monte Carlo.

O método de Monte Carlo consiste em escolher experimentos de amostra aleatoriamente de um grande conjunto e, em seguida, fazer deduções com base nas probabilidades estimadas a partir da tabulação dos resultados desses experimentos. Por exemplo, podemos aproximar π usando o fato de que 6 / π 2 é a probabilidade de que dois inteiros escolhidos aleatoriamente não tenham fatores em comum; ou seja, que seu máximo divisor comum seja 1.135 Para obter a aproximação de π , realizamos um grande número de experimentos. Em cada experimento, escolhemos dois inteiros aleatoriamente e realizamos um teste para ver se seu GCD é 1. A fração de vezes que o teste é passado nos dá nossa estimativa de 6 / π 2 , e a partir disso obtemos nossa aproximação de π .

O coração do nosso programa é um procedimento monte-carlo, que recebe como argumentos o número de vezes para tentar um experimento, juntamente com o experimento, representado como um procedimento sem argumentos que retorna verdadeiro ou falso cada vez que é executado. Monte-carlo executa o experimento pelo número designado de tentativas e retorna um número informando a fração das tentativas em que o experimento foi considerado verdadeiro.

(define (estimate-pi trials)
(sqrt (/ 6 (monte-carlo trials cesaro-test))))

(define (cesaro-test)
 (= (gcd (rand) (rand)) 1))

(define (monte-carlo trials experiment)
(define (iter trials-remaining trials-passed)
  (cond ((= trials-remaining 0)
         (/ trials-passed trials))
        ((experiment)
         (iter (- trials-remaining 1) 
               (+ trials-passed 1)))
        (else
         (iter (- trials-remaining 1) 
               trials-passed))))
(iter trials 0))

Agora vamos tentar a mesma computação usando rand-update diretamente em vez de rand, da forma como seríamos forçados a proceder se não usássemos atribuição para modelar estado local:

(define (estimate-pi trials)
(sqrt (/ 6 (random-gcd-test trials random-init))))

(define (random-gcd-test trials initial-x)
(define (iter trials-remaining trials-passed x)
  (let ((x1 (rand-update x)))
    (let ((x2 (rand-update x1)))
      (cond ((= trials-remaining 0)
             (/ trials-passed trials))
            ((= (gcd x1 x2) 1)
             (iter (- trials-remaining 1)
                   (+ trials-passed 1)
                   x2))
            (else
             (iter (- trials-remaining 1)
                   trials-passed
                   x2))))))
(iter trials 0 initial-x))

Embora o programa ainda seja simples, ele revela algumas violações dolorosas de modularidade. Em nossa primeira versão do programa, usando rand, podemos expressar o método de Monte Carlo diretamente como um procedimento geral monte-carlo que recebe como argumento um procedimento experiment arbitrário. Em nossa segunda versão do programa, sem estado local para o gerador de números aleatórios, random-gcd-test deve manipular explicitamente os números aleatórios x1 e x2 e reciclar x2 através do loop iterativo como a nova entrada para rand-update. Esse manuseio explícito dos números aleatórios entrelaça a estrutura de acumulação de resultados de teste com o fato de que nosso experimento específico usa dois números aleatórios, enquanto outros experimentos de Monte Carlo podem usar um número aleatório ou três. Até o procedimento de nível superior estimate-pi tem que se preocupar em fornecer um número aleatório inicial. O fato de que as entranhas do gerador de números aleatórios estão vazando para outras partes do programa torna difícil para nós isolar a ideia de Monte Carlo para que ela possa ser aplicada a outras tarefas. Na primeira versão do programa, a atribuição encapsula o estado do gerador de números aleatórios dentro do procedimento rand, de modo que os detalhes da geração de números aleatórios permanecem independentes do resto do programa.

O fenômeno geral ilustrado pelo exemplo de Monte Carlo é este: Do ponto de vista de uma parte de um processo complexo, as outras partes parecem mudar com o tempo. Elas têm estado local oculto que varia com o tempo. Se desejamos escrever programas de computador cuja estrutura reflita essa decomposição, fazemos objetos computacionais (como contas bancárias e geradores de números aleatórios) cujo comportamento muda com o tempo. Modelamos estado com variáveis de estado locais e modelamos as mudanças de estado com atribuições a essas variáveis.

É tentador concluir esta discussão dizendo que, ao introduzir atribuição e a técnica de ocultar estado em variáveis locais, somos capazes de estruturar sistemas de uma forma mais modular do que se todo o estado tivesse que ser manipulado explicitamente, passando parâmetros adicionais. Infelizmente, como veremos, a história não é tão simples.

Exercício 3.5: Integração de Monte Carlo é um método de estimar integrais definidas por meio de simulação de Monte Carlo. Considere calcular a área de uma região do espaço descrita por um predicado P ( x , y ) que é verdadeiro para pontos ( x , y ) na região e falso para pontos fora da região. Por exemplo, a região contida dentro de um círculo de raio 3 centrado em (5, 7) é descrita pelo predicado que testa se ( x 5 ) 2 + ( y 7 ) 2 3 2 . Para estimar a área da região descrita por tal predicado, comece escolhendo um retângulo que contenha a região. Por exemplo, um retângulo com cantos diagonalmente opostos em (2, 4) e (8, 10) contém o círculo acima. A integral desejada é a área da porção do retângulo que está na região. Podemos estimar a integral escolhendo, aleatoriamente, pontos ( x , y ) que estão no retângulo e testando P ( x , y ) para cada ponto para determinar se o ponto está na região. Se tentarmos isso com muitos pontos, então a fração de pontos que caem na região deve dar uma estimativa da proporção do retângulo que está na região. Portanto, multiplicando essa fração pela área de todo o retângulo, devemos produzir uma estimativa da integral.

Implemente a integração de Monte Carlo como um procedimento estimate-integral que recebe como argumentos um predicado P, limites superiores e inferiores x1, x2, y1 e y2 para o retângulo, e o número de tentativas a serem realizadas para produzir a estimativa. Seu procedimento deve usar o mesmo procedimento monte-carlo que foi usado acima para estimar π . Use seu estimate-integral para produzir uma estimativa de π medindo a área de um círculo unitário.

Você achará útil ter um procedimento que retorna um número escolhido aleatoriamente de um determinado intervalo. O seguinte procedimento random-in-range implementa isso em termos do procedimento random usado em 1.2.6, que retorna um número não negativo menor que sua entrada.136

(define (random-in-range low high)
(let ((range (- high low)))
  (+ low (random range))))

Exercício 3.6: É útil poder redefinir um gerador de números aleatórios para produzir uma sequência começando de um determinado valor. Projete um novo procedimento rand que é chamado com um argumento que é o símbolo generate ou o símbolo reset e se comporta da seguinte forma: (rand 'generate) produz um novo número aleatório; ((rand 'reset) ⟨new-value⟩) redefine a variável de estado interno para o ⟨new-value⟩ designado. Assim, ao redefinir o estado, pode-se gerar sequências repetíveis. Essas são muito úteis ao testar e depurar programas que usam números aleatórios.

3.1.3Os Custos de Introduzir Atribuição

Como vimos, a operação set! nos permite modelar objetos que têm estado local. No entanto, essa vantagem tem um preço. Nossa linguagem de programação não pode mais ser interpretada em termos do modelo de substituição de aplicação de procedimentos que introduzimos em 1.1.5. Além disso, nenhum modelo simples com propriedades matemáticas "agradáveis" pode ser uma estrutura adequada para lidar com objetos e atribuição em linguagens de programação.

Enquanto não usarmos atribuições, duas avaliações do mesmo procedimento com os mesmos argumentos produzirão o mesmo resultado, de modo que os procedimentos podem ser vistos como computando funções matemáticas. Programar sem usar atribuições, como fizemos ao longo dos dois primeiros capítulos deste livro, é conhecido como programação funcional.

Para entender como a atribuição complica as coisas, considere uma versão simplificada do procedimento make-withdraw de 3.1.1 que não se preocupa em verificar uma quantia insuficiente:

(define (make-simplified-withdraw balance)
(lambda (amount)
(set! balance (- balance amount))
balance))

(define W (make-simplified-withdraw 25))

(W 20)
5

(W 10)
-5

Compare este procedimento com o seguinte procedimento make-decrementer, que não usa set!:

(define (make-decrementer balance)
(lambda (amount)
(- balance amount)))

Make-decrementer retorna um procedimento que subtrai sua entrada de uma quantia designada balance, mas não há efeito acumulado ao longo de chamadas sucessivas, como com make-simplified-withdraw:

(define D (make-decrementer 25))

(D 20)
5

(D 10)
15

Podemos usar o modelo de substituição para explicar como make-decrementer funciona. Por exemplo, vamos analisar a avaliação da expressão

((make-decrementer 25) 20)

Primeiro simplificamos o operador da combinação substituindo 25 por balance no corpo de make-decrementer. Isso reduz a expressão para

((lambda (amount) (- 25 amount)) 20)

Agora aplicamos o operador substituindo 20 por amount no corpo da expressão lambda:

(- 25 20)

A resposta final é 5.

Observe, no entanto, o que acontece se tentarmos uma análise de substituição semelhante com make-simplified-withdraw:

((make-simplified-withdraw 25) 20)

Primeiro simplificamos o operador substituindo 25 por balance no corpo de make-simplified-withdraw. Isso reduz a expressão para137

((lambda (amount) 
(set! balance (- 25 amount)) 25)
20)

Agora aplicamos o operador substituindo 20 por amount no corpo da expressão lambda:

(set! balance (- 25 20)) 25

Se aderíssemos ao modelo de substituição, teríamos que dizer que o significado da aplicação do procedimento é primeiro definir balance para 5 e depois retornar 25 como o valor da expressão. Isso dá a resposta errada. Para obter a resposta correta, teríamos que de alguma forma distinguir a primeira ocorrência de balance (antes do efeito do set!) da segunda ocorrência de balance (após o efeito do set!), e o modelo de substituição não pode fazer isso.

O problema aqui é que a substituição é baseada fundamentalmente na noção de que os símbolos em nossa linguagem são essencialmente nomes para valores. Mas assim que introduzimos set! e a ideia de que o valor de uma variável pode mudar, uma variável não pode mais ser simplesmente um nome. Agora uma variável de alguma forma se refere a um lugar onde um valor pode ser armazenado, e o valor armazenado nesse lugar pode mudar. Em 3.2, veremos como os ambientes desempenham esse papel de "lugar" em nosso modelo computacional.

Identidade e Mudança

A questão que surge aqui é mais profunda do que o mero colapso de um modelo particular de computação. Assim que introduzimos mudança em nossos modelos computacionais, muitas noções que antes eram simples se tornam problemáticas. Considere o conceito de duas coisas serem "a mesma".

Suponha que chamemos make-decrementer duas vezes com o mesmo argumento para criar dois procedimentos:

(define D1 (make-decrementer 25))
(define D2 (make-decrementer 25))

D1 e D2 são a mesma coisa? Uma resposta aceitável é sim, porque D1 e D2 têm o mesmo comportamento computacional — cada um é um procedimento que subtrai sua entrada de 25. De fato, D1 poderia ser substituído por D2 em qualquer computação sem alterar o resultado.

Compare isso com fazer duas chamadas a make-simplified-withdraw:

(define W1 (make-simplified-withdraw 25))
(define W2 (make-simplified-withdraw 25))

W1 e W2 são a mesma coisa? Certamente não, porque chamadas a W1 e W2 têm efeitos distintos, como mostrado pela seguinte sequência de interações:

(W1 20)
5

(W1 20)
-15

(W2 20)
5

Embora W1 e W2 sejam "iguais" no sentido de que ambos são criados avaliando a mesma expressão, (make-simplified-withdraw 25), não é verdade que W1 poderia ser substituído por W2 em qualquer expressão sem alterar o resultado da avaliação da expressão.

Uma linguagem que suporta o conceito de que "iguais podem ser substituídos por iguais" em uma expressão sem alterar o valor da expressão é dita referencialmente transparente. A transparência referencial é violada quando incluímos set! em nossa linguagem de computador. Isso torna complicado determinar quando podemos simplificar expressões substituindo expressões equivalentes. Consequentemente, raciocinar sobre programas que usam atribuição torna-se drasticamente mais difícil.

Uma vez que abandonamos a transparência referencial, a noção do que significa para objetos computacionais serem "a mesma coisa" torna-se difícil de capturar de forma formal. De fato, o significado de "mesmo" no mundo real que nossos programas modelam dificilmente é claro em si mesmo. Em geral, podemos determinar que dois objetos aparentemente idênticos são de fato "a mesma coisa" apenas modificando um objeto e observando se o outro objeto mudou da mesma forma. Mas como podemos dizer se um objeto "mudou" a não ser observando o "mesmo" objeto duas vezes e vendo se alguma propriedade do objeto difere de uma observação para a próxima? Assim, não podemos determinar "mudança" sem alguma noção a priori de "mesmo".

Como um exemplo de como essa questão surge na programação, considere a situação em que Pedro e Paulo têm uma conta bancária com $100. Há uma diferença substancial entre modelar isso como

(define peter-acc (make-account 100))
(define paul-acc (make-account 100))

e modelar isso como

(define peter-acc (make-account 100))
(define paul-acc peter-acc)

Na primeira situação, as duas contas bancárias são distintas. Transações feitas por Pedro não afetarão a conta de Paulo, e vice-versa. Na segunda situação, no entanto, definimos paul-acc como a mesma coisa que peter-acc. Efetivamente, Pedro e Paulo agora têm uma conta bancária conjunta, e se Pedro fizer um saque de peter-acc, Paulo observará menos dinheiro em paul-acc. Essas duas situações semelhantes, mas distintas, podem causar confusão na construção de modelos computacionais. Com a conta compartilhada, em particular, pode ser especialmente confuso que haja um objeto (a conta bancária) que tem dois nomes diferentes (peter-acc e paul-acc); se estivermos procurando por todos os lugares em nosso programa onde paul-acc pode ser alterado, devemos lembrar de olhar também para coisas que alteram peter-acc.138

Com referência às observações acima sobre "mesmo" e "mudança", observe que se Pedro e Paulo pudessem apenas examinar seus saldos bancários e não pudessem realizar operações que alterassem o saldo, então a questão de saber se as duas contas são distintas seria irrelevante. Em geral, desde que nunca modifiquemos objetos de dados, podemos considerar um objeto de dados composto como precisamente a totalidade de suas partes. Por exemplo, um número racional é determinado por seu numerador e seu denominador. Mas essa visão não é mais válida na presença de mudança, onde um objeto de dados composto tem uma "identidade" que é algo diferente das partes das quais é composto. Uma conta bancária ainda é "a mesma" conta bancária mesmo que alteremos o saldo fazendo um saque; inversamente, poderíamos ter duas contas bancárias diferentes com as mesmas informações de estado. Essa complicação é uma consequência, não de nossa linguagem de programação, mas de nossa percepção de uma conta bancária como um objeto. Não consideramos, por exemplo, um número racional como um objeto mutável com identidade, de modo que poderíamos mudar o numerador e ainda ter "o mesmo" número racional.

Armadilhas da Programação Imperativa

Em contraste com a programação funcional, a programação que faz uso extensivo de atribuição é conhecida como programação imperativa. Além de levantar complicações sobre modelos computacionais, programas escritos em estilo imperativo são suscetíveis a bugs que não podem ocorrer em programas funcionais. Por exemplo, lembre-se do programa iterativo de fatorial de 1.2.1:

(define (factorial n)
(define (iter product counter)
(if (> counter n)
    product
    (iter (* counter product)
          (+ counter 1))))
(iter 1 1))

Em vez de passar argumentos no loop iterativo interno, poderíamos adotar um estilo mais imperativo usando atribuição explícita para atualizar os valores das variáveis product e counter:

(define (factorial n)
(let ((product 1)
    (counter 1))
(define (iter)
  (if (> counter n)
      product
      (begin (set! product (* counter product))
             (set! counter (+ counter 1))
             (iter))))
(iter)))

Isso não altera os resultados produzidos pelo programa, mas introduz uma armadilha sutil. Como decidimos a ordem das atribuições? Como está escrito, o programa está correto. Mas escrever as atribuições na ordem oposta

(set! counter (+ counter 1))
(set! product (* counter product))

teria produzido um resultado diferente e incorreto. Em geral, programar com atribuição nos força a considerar cuidadosamente as ordens relativas das atribuições para garantir que cada instrução esteja usando a versão correta das variáveis que foram alteradas. Essa questão simplesmente não surge em programas funcionais.139

A complexidade dos programas imperativos torna-se ainda pior se considerarmos aplicações em que vários processos executam concorrentemente. Voltaremos a isso em 3.4. Primeiro, no entanto, abordaremos a questão de fornecer um modelo computacional para expressões que envolvem atribuição e exploraremos os usos de objetos com estado local no design de simulações.

Exercício 3.7: Considere os objetos de conta bancária criados por make-account, com a modificação de senha descrita no Exercício 3.3. Suponha que nosso sistema bancário exija a capacidade de fazer contas conjuntas. Defina um procedimento make-joint que realiza isso. Make-joint deve receber três argumentos. O primeiro é uma conta protegida por senha. O segundo argumento deve corresponder à senha com a qual a conta foi definida para que a operação make-joint prossiga. O terceiro argumento é uma nova senha. Make-joint deve criar um acesso adicional à conta original usando a nova senha. Por exemplo, se peter-acc é uma conta bancária com senha open-sesame, então

(define paul-acc
(make-joint peter-acc 
          'open-sesame 
          'rosebud))

permitirá que se façam transações em peter-acc usando o nome paul-acc e a senha rosebud. Você pode querer modificar sua solução para o Exercício 3.3 para acomodar esse novo recurso.

Exercício 3.8: Quando definimos o modelo de avaliação em 1.1.3, dissemos que o primeiro passo na avaliação de uma expressão é avaliar suas subexpressões. Mas nunca especificamos a ordem em que as subexpressões devem ser avaliadas (por exemplo, da esquerda para a direita ou da direita para a esquerda). Quando introduzimos atribuição, a ordem em que os argumentos para um procedimento são avaliados pode fazer diferença no resultado. Defina um procedimento simples f tal que avaliar

(+ (f 0) (f 1))

retornará 0 se os argumentos para + forem avaliados da esquerda para a direita, mas retornará 1 se os argumentos forem avaliados da direita para a esquerda.

Notas de Rodapé

129 Na verdade, isso não é exatamente verdade. Uma exceção foi o gerador de números aleatórios em 1.2.6. Outra exceção envolveu as tabelas de operação/tipo que introduzimos em 2.4.3, onde os valores de duas chamadas para get com os mesmos argumentos dependiam de chamadas intermediárias para put. Por outro lado, até introduzirmos atribuição, não temos como criar tais procedimentos nós mesmos.

130 O valor de uma expressão set! é dependente da implementação. Set! deve ser usado apenas para seu efeito, não para seu valor.

O nome set! reflete uma convenção de nomenclatura usada em Scheme: Operações que alteram os valores de variáveis (ou que alteram estruturas de dados, como veremos em 3.3) recebem nomes que terminam com um ponto de exclamação. Isso é semelhante à convenção de designar predicados por nomes que terminam com um ponto de interrogação.

131 Já usamos begin implicitamente em nossos programas, porque em Scheme o corpo de um procedimento pode ser uma sequência de expressões. Além disso, a parte consequent de cada cláusula em uma expressão cond pode ser uma sequência de expressões em vez de uma única expressão.

132 No jargão de linguagens de programação, a variável balance é dita encapsulada dentro do procedimento new-withdraw. O encapsulamento reflete o princípio geral de design de sistemas conhecido como princípio de ocultação: Pode-se tornar um sistema mais modular e robusto protegendo partes do sistema umas das outras; ou seja, fornecendo acesso à informação apenas para aquelas partes do sistema que têm uma "necessidade de saber".

133 Em contraste com new-withdraw acima, não precisamos usar let para tornar balance uma variável local, já que parâmetros formais já são locais. Isso ficará mais claro após a discussão do modelo de ambiente de avaliação em 3.2. (Veja também Exercício 3.10.)

134 Uma maneira comum de implementar rand-update é usar a regra de que x é atualizado para a x + b módulo m , onde a , b , e m são inteiros escolhidos apropriadamente. O Capítulo 3 de Knuth 1981 inclui uma discussão extensa sobre técnicas para gerar sequências de números aleatórios e estabelecer suas propriedades estatísticas. Observe que o procedimento rand-update calcula uma função matemática: Dada a mesma entrada duas vezes, ele produz a mesma saída. Portanto, a sequência de números produzida por rand-update certamente não é "aleatória", se por "aleatória" insistirmos que cada número na sequência seja independente do número anterior. A relação entre "aleatoriedade real" e as chamadas sequências pseudo-aleatórias, que são produzidas por computações bem determinadas e ainda assim têm propriedades estatísticas adequadas, é uma questão complexa envolvendo questões difíceis em matemática e filosofia. Kolmogorov, Solomonoff e Chaitin fizeram grandes progressos em esclarecer essas questões; uma discussão pode ser encontrada em Chaitin 1975.

135 Este teorema é devido a E. Cesàro. Veja a seção 4.5.2 de Knuth 1981 para uma discussão e uma prova.

136 O Scheme do MIT fornece tal procedimento. Se random receber um inteiro exato (como em 1.2.6), ele retorna um inteiro exato, mas se receber um valor decimal (como neste exercício), ele retorna um valor decimal.

137 Não substituímos a ocorrência de balance na expressão set! porque o name em um set! não é avaliado. Se substituíssemos por ele, obteríamos (set! 25 (- 25 amount)), o que não faz sentido.

138 O fenômeno de um único objeto computacional ser acessado por mais de um nome é conhecido como aliasing. A situação da conta bancária conjunta ilustra um exemplo muito simples de um alias. Em 3.3 veremos exemplos muito mais complexos, como estruturas de dados compostas "distintas" que compartilham partes. Bugs podem ocorrer em nossos programas se esquecermos que uma mudança em um objeto também pode, como um "efeito colateral", alterar um "objeto diferente" porque os dois "objetos diferentes" são na verdade um único objeto aparecendo sob diferentes aliases. Esses chamados bugs de efeito colateral são tão difíceis de localizar e analisar que algumas pessoas propuseram que linguagens de programação sejam projetadas de forma a não permitir efeitos colaterais ou aliasing (Lampson et al. 1981; Morris et al. 1980).

139 Em vista disso, é irônico que a programação introdutória seja mais frequentemente ensinada em um estilo altamente imperativo. Isso pode ser um vestígio de uma crença, comum durante as décadas de 1960 e 1970, de que programas que chamam procedimentos devem ser inerentemente menos eficientes do que programas que realizam atribuições. (Steele 1977 desmascara esse argumento.) Alternativamente, pode refletir uma visão de que a atribuição passo a passo é mais fácil para iniciantes visualizarem do que a chamada de procedimento. Seja qual for o motivo, muitas vezes sobrecarrega programadores iniciantes com preocupações do tipo "devo definir esta variável antes ou depois daquela" que podem complicar a programação e obscurecer as ideias importantes.