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.
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
até
sejam avaliadas em sequência, e o valor da expressão final
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 paramake-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 pormake-monitored
é um terceiro procedimento, digamosmf
, que mantém um contador interno do número de vezes que foi chamado. Se a entrada paramf
for o símbolo especialhow-many-calls?
, entãomf
retorna o valor do contador. Se a entrada for o símbolo especialreset-count
, entãomf
redefine o contador para zero. Para qualquer outra entrada,mf
retorna o resultado de chamarf
nessa entrada e incrementa o contador. Por exemplo, poderíamos fazer uma versão monitorada do procedimentosqrt
:(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 procedimentocall-the-cops
.
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
e formarmos
x₂ = (rand-update x₁)
x₃ = (rand-update x₂)
então a sequência de valores , , , … 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 é 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 , 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 que é verdadeiro para pontos 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 . 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 que estão no retângulo e testando 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 predicadoP
, limites superiores e inferioresx1
,x2
,y1
ey2
para o retângulo, e o número de tentativas a serem realizadas para produzir a estimativa. Seu procedimento deve usar o mesmo procedimentomonte-carlo
que foi usado acima para estimar . Use seuestimate-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 procedimentorandom
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ímbologenerate
ou o símboloreset
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.
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.
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.
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 procedimentomake-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çãomake-joint
prossiga. O terceiro argumento é uma nova senha.Make-joint
deve criar um acesso adicional à conta original usando a nova senha. Por exemplo, sepeter-acc
é uma conta bancária com senhaopen-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 nomepaul-acc
e a senharosebud
. 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.
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
é atualizado para
módulo
, onde
,
, e
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.