Nosso avaliador para Lisp será implementado como um programa Lisp. Pode parecer circular pensar em avaliar programas Lisp usando um avaliador que é ele mesmo implementado em Lisp. No entanto, a avaliação é um processo, então é apropriado descrever o processo de avaliação usando Lisp, que, afinal, é nossa ferramenta para descrever processos.207 Um avaliador que é escrito na mesma linguagem que ele avalia é chamado de metacircular.
O avaliador metacircular é essencialmente uma formulação em Scheme do modelo de avaliação de ambiente descrito em 3.2. Lembre-se de que o modelo tem duas partes básicas:
Essas duas regras descrevem a essência do processo de avaliação, um
ciclo básico no qual expressões a serem avaliadas em ambientes são
reduzidas a procedimentos a serem aplicados a argumentos, que por sua
vez são reduzidos a novas expressões a serem avaliadas em novos
ambientes, e assim por diante, até chegarmos a símbolos, cujos valores
são procurados no ambiente, e a procedimentos primitivos, que são
aplicados diretamente (veja Figura 4.1).208
Este ciclo de avaliação será incorporado pela interação entre os dois
procedimentos críticos no avaliador, eval
e
apply
, que são descritos em
4.1.1 (veja
Figura 4.1).
Figura 4.1: O ciclo eval
-apply
expõe a essência de uma linguagem de computador.
A implementação do avaliador dependerá de procedimentos que definem a
sintaxe das expressões a serem
avaliadas. Usaremos abstração de dados para tornar o avaliador
independente da representação da linguagem. Por exemplo, em vez de nos
comprometermos com a escolha de que uma atribuição seja representada por
uma lista começando com o símbolo set!
, usamos um predicado
abstrato assignment?
para testar uma atribuição, e usamos
seletores abstratos assignment-variable
e
assignment-value
para acessar as partes de uma atribuição.
A implementação de expressões será descrita em detalhes em
4.1.2. Há também operações, descritas em
4.1.3, que especificam a representação
de procedimentos e ambientes. Por exemplo,
make-procedure
constrói procedimentos compostos,
lookup-variable-value
acessa os valores das variáveis, e
apply-primitive-procedure
aplica um procedimento primitivo
a uma dada lista de argumentos.
O processo de avaliação pode ser descrito como a interação entre dois
procedimentos: eval
e apply
.
Eval
recebe como argumentos uma expressão e um ambiente.
Ele classifica a expressão e direciona sua avaliação.
Eval
é estruturado como uma análise de caso do tipo
sintático da expressão a ser avaliada. Para manter o procedimento geral,
expressamos a determinação do tipo de uma expressão de forma abstrata,
sem nos comprometermos com nenhuma representação particular para os
vários tipos de expressões. Cada tipo de expressão tem um predicado que
testa por ele e um meio abstrato para selecionar suas partes. Esta
sintaxe abstrata facilita
ver como podemos mudar a sintaxe da linguagem usando o mesmo avaliador,
mas com uma coleção diferente de procedimentos de sintaxe.
Expressões primitivas
eval
retorna a própria expressão.
Eval
deve procurar variáveis no ambiente para encontrar
seus valores.
Formas especiais
eval
retorna a expressão que foi
citada.
eval
para calcular o novo valor a ser
associado à variável. O ambiente deve ser modificado para alterar (ou
criar) a vinculação da variável.
if
requer processamento especial de suas
partes, de modo a avaliar o consequente se o predicado for verdadeiro,
e caso contrário, avaliar a alternativa.
lambda
deve ser transformada em um
procedimento aplicável, empacotando os parâmetros e o corpo
especificados pela expressão lambda
com o ambiente da
avaliação.
begin
requer a avaliação de sua sequência
de expressões na ordem em que aparecem.
cond
) é transformada em um ninho de
expressões if
e então avaliada.
Combinações
eval
deve avaliar
recursivamente a parte do operador e os operandos da combinação. O
procedimento resultante e os argumentos são passados para
apply
, que lida com a aplicação real do procedimento.
Aqui está a definição de eval
:
(define (eval exp env)
(cond ((self-evaluating? exp)
exp)
((variable? exp)
(lookup-variable-value exp env))
((quoted? exp)
(text-of-quotation exp))
((assignment? exp)
(eval-assignment exp env))
((definition? exp)
(eval-definition exp env))
((if? exp)
(eval-if exp env))
((lambda? exp)
(make-procedure
(lambda-parameters exp)
(lambda-body exp)
env))
((begin? exp)
(eval-sequence
(begin-actions exp)
env))
((cond? exp)
(eval (cond->if exp) env))
((application? exp)
(apply (eval (operator exp) env)
(list-of-values
(operands exp)
env)))
(else
(error "Unknown expression
type: EVAL" exp))))
Para clareza, eval
foi implementado como uma análise de
caso usando cond
. A desvantagem disso é que nosso
procedimento lida apenas com alguns tipos distinguíveis de expressões, e
nenhum novo pode ser definido sem editar a definição de
eval
. Na maioria das implementações de Lisp, a despacho
sobre o tipo de uma expressão é feito de forma dirigida por dados. Isso
permite que um usuário adicione novos tipos de expressões que
eval
pode distinguir, sem modificar a definição de
eval
em si. (Veja
Exercício 4.3.)
Apply
recebe dois argumentos, um procedimento e uma lista
de argumentos aos quais o procedimento deve ser aplicado.
Apply
classifica procedimentos em dois tipos: Ele chama
apply-primitive-procedure
para aplicar primitivas; ele
aplica procedimentos compostos avaliando sequencialmente as expressões
que compõem o corpo do procedimento. O ambiente para a avaliação do
corpo de um procedimento composto é construído estendendo o ambiente
base carregado pelo procedimento para incluir um quadro que vincula os
parâmetros do procedimento aos argumentos aos quais o procedimento é
aplicado. Aqui está a definição de apply
:
(define (apply procedure arguments)
(cond ((primitive-procedure? procedure)
(apply-primitive-procedure
procedure
arguments))
((compound-procedure? procedure)
(eval-sequence
(procedure-body procedure)
(extend-environment
(procedure-parameters
procedure)
arguments
(procedure-environment
procedure))))
(else
(error "Unknown procedure
type: APPLY"
procedure))))
Quando eval
processa uma aplicação de procedimento, ele usa
list-of-values
para produzir a lista de argumentos aos
quais o procedimento deve ser aplicado.
List-of-values
recebe como argumento os operandos da
combinação. Ele avalia cada operando e retorna uma lista dos valores
correspondentes:209
(define (list-of-values exps env)
(if (no-operands? exps)
'()
(cons (eval (first-operand exps) env)
(list-of-values
(rest-operands exps)
env))))
Eval-if
avalia a parte do predicado de uma expressão
if
no ambiente dado. Se o resultado for verdadeiro,
eval-if
avalia o consequente, caso contrário, avalia a
alternativa:
(define (eval-if exp env)
(if (true? (eval (if-predicate exp) env))
(eval (if-consequent exp) env)
(eval (if-alternative exp) env)))
O uso de true?
em eval-if
destaca a questão da
conexão entre uma linguagem implementada e uma linguagem de
implementação. O if-predicate
é avaliado na linguagem sendo
implementada e, portanto, produz um valor nessa linguagem. O predicado
do interpretador true?
traduz esse valor em um valor que
pode ser testado pelo if
na linguagem de implementação: A
representação metacircular da verdade pode não ser a mesma que a do
Scheme subjacente.210
Eval-sequence
é usado por apply
para avaliar a
sequência de expressões no corpo de um procedimento e por
eval
para avaliar a sequência de expressões em uma
expressão begin
. Ele recebe como argumentos uma sequência
de expressões e um ambiente, e avalia as expressões na ordem em que
ocorrem. O valor retornado é o valor da expressão final.
(define (eval-sequence exps env)
(cond ((last-exp? exps)
(eval (first-exp exps) env))
(else
(eval (first-exp exps) env)
(eval-sequence (rest-exps exps)
env))))
O seguinte procedimento lida com atribuições a variáveis. Ele chama
eval
para encontrar o valor a ser atribuído e transmite a
variável e o valor resultante para set-variable-value!
para
ser instalado no ambiente designado.
(define (eval-assignment exp env)
(set-variable-value!
(assignment-variable exp)
(eval (assignment-value exp) env)
env)
'ok)
As definições de variáveis são tratadas de maneira semelhante.211
(define (eval-definition exp env)
(define-variable!
(definition-variable exp)
(eval (definition-value exp) env)
env)
'ok)
Escolhemos aqui retornar o símbolo ok
como o valor de uma
atribuição ou uma definição.212
Exercício 4.1: Observe que não podemos dizer se o avaliador metacircular avalia operandos da esquerda para a direita ou da direita para a esquerda. Sua ordem de avaliação é herdada do Lisp subjacente: Se os argumentos para
cons
emlist-of-values
são avaliados da esquerda para a direita, entãolist-of-values
avaliará operandos da esquerda para a direita; e se os argumentos paracons
são avaliados da direita para a esquerda, entãolist-of-values
avaliará operandos da direita para a esquerda.Escreva uma versão de
list-of-values
que avalia operandos da esquerda para a direita, independentemente da ordem de avaliação no Lisp subjacente. Também escreva uma versão delist-of-values
que avalia operandos da direita para a esquerda.
O avaliador é reminiscente do programa de diferenciação simbólica discutido em 2.3.2. Ambos os programas operam em expressões simbólicas. Em ambos os programas, o resultado de operar em uma expressão composta é determinado operando recursivamente nas partes da expressão e combinando os resultados de uma forma que depende do tipo da expressão. Em ambos os programas, usamos abstração de dados para desacoplar as regras gerais de operação dos detalhes de como as expressões são representadas. No programa de diferenciação, isso significava que o mesmo procedimento de diferenciação poderia lidar com expressões algébricas em forma de prefixo, em forma de infixo ou em alguma outra forma. Para o avaliador, isso significa que a sintaxe da linguagem sendo avaliada é determinada unicamente pelos procedimentos que classificam e extraem partes das expressões.
Aqui está a especificação da sintaxe da nossa linguagem:
(define (self-evaluating? exp)
(cond ((number? exp) true)
((string? exp) true)
(else false)))
(define (variable? exp) (symbol? exp))
(quote ⟨text-of-quotation⟩)
:213
(define (quoted? exp)
(tagged-list? exp 'quote))
(define (text-of-quotation exp)
(cadr exp))
Quoted?
é definido em termos do procedimento
tagged-list?
, que identifica listas começando com um
símbolo designado:
(define (tagged-list? exp tag)
(if (pair? exp)
(eq? (car exp) tag)
false))
(set! ⟨var⟩ ⟨value⟩)
:
(define (assignment? exp)
(tagged-list? exp 'set!))
(define (assignment-variable exp)
(cadr exp))
(define (assignment-value exp) (caddr exp))
(define ⟨var⟩ ⟨value⟩)
ou a forma
(define (⟨var⟩ ⟨param₁⟩ … ⟨paramₙ⟩)
⟨body⟩)
A última forma (definição padrão de procedimento) é açúcar sintático para
(define ⟨var⟩
(lambda (⟨param₁⟩ … ⟨paramₙ⟩)
⟨body⟩))
Os procedimentos de sintaxe correspondentes são os seguintes:
(define (definition? exp)
(tagged-list? exp 'define))
(define (definition-variable exp)
(if (symbol? (cadr exp))
(cadr exp)
(caadr exp)))
(define (definition-value exp)
(if (symbol? (cadr exp))
(caddr exp)
(make-lambda
(cdadr exp) ; parâmetros formais
(cddr exp)))) ; corpo
lambda
são listas que começam com o símbolo
lambda
:
(define (lambda? exp)
(tagged-list? exp 'lambda))
(define (lambda-parameters exp) (cadr exp))
(define (lambda-body exp) (cddr exp))
Também fornecemos um construtor para expressões lambda
,
que é usado por definition-value
, acima:
(define (make-lambda parameters body)
(cons 'lambda (cons parameters body)))
if
e têm um predicado, um
consequente e uma alternativa (opcional). Se a expressão não tiver
parte alternativa, fornecemos false
como alternativa.214
(define (if? exp) (tagged-list? exp 'if))
(define (if-predicate exp) (cadr exp))
(define (if-consequent exp) (caddr exp))
(define (if-alternative exp)
(if (not (null? (cdddr exp)))
(cadddr exp)
'false))
Também fornecemos um construtor para expressões if
, a
ser usado por cond->if
para transformar expressões
cond
em expressões if
:
(define (make-if predicate
consequent
alternative)
(list 'if
predicate
consequent
alternative))
Begin
empacota uma sequência de expressões em uma única
expressão. Incluímos operações de sintaxe em expressões
begin
para extrair a sequência real da expressão
begin
, bem como seletores que retornam a primeira
expressão e o restante das expressões na sequência.215
(define (begin? exp)
(tagged-list? exp 'begin))
(define (begin-actions exp) (cdr exp))
(define (last-exp? seq) (null? (cdr seq)))
(define (first-exp seq) (car seq))
(define (rest-exps seq) (cdr seq))
Também incluímos um construtor sequence->exp
(para uso
por cond->if
) que transforma uma sequência em uma única
expressão, usando begin
se necessário:
(define (sequence->exp seq)
(cond ((null? seq) seq)
((last-exp? seq) (first-exp seq))
(else (make-begin seq))))
(define (make-begin seq) (cons 'begin seq))
car
da expressão
é o operador, e o cdr
é a lista de operandos:
(define (application? exp) (pair? exp))
(define (operator exp) (car exp))
(define (operands exp) (cdr exp))
(define (no-operands? ops) (null? ops))
(define (first-operand ops) (car ops))
(define (rest-operands ops) (cdr ops))
Algumas formas especiais em nossa linguagem podem ser definidas em
termos de expressões envolvendo outras formas especiais, em vez de serem
implementadas diretamente. Um exemplo é cond
, que pode ser
implementado como um ninho de expressões if
. Por exemplo,
podemos reduzir o problema de avaliar a expressão
(cond ((> x 0) x)
((= x 0) (display 'zero) 0)
(else (- x)))
ao problema de avaliar a seguinte expressão envolvendo if
e
begin
:
(if (> x 0)
x
(if (= x 0)
(begin (display 'zero) 0)
(- x)))
Implementar a avaliação de cond
dessa forma simplifica o
avaliador porque reduz o número de formas especiais para as quais o
processo de avaliação deve ser explicitamente especificado.
Incluímos procedimentos de sintaxe que extraem as partes de uma
expressão cond
, e um procedimento cond->if
que
transforma expressões cond
em expressões if
.
Uma análise de caso começa com cond
e tem uma lista de
cláusulas predicado-ação. Uma cláusula é uma cláusula
else
se seu predicado for o símbolo else
.216
(define (cond? exp)
(tagged-list? exp 'cond))
(define (cond-clauses exp) (cdr exp))
(define (cond-else-clause? clause)
(eq? (cond-predicate clause) 'else))
(define (cond-predicate clause)
(car clause))
(define (cond-actions clause)
(cdr clause))
(define (cond->if exp)
(expand-clauses (cond-clauses exp)))
(define (expand-clauses clauses)
(if (null? clauses)
'false ; sem cláusula else
(let ((first (car clauses))
(rest (cdr clauses)))
(if (cond-else-clause? first)
(if (null? rest)
(sequence->exp
(cond-actions first))
(error "ELSE clause isn't
last: COND->IF"
clauses))
(make-if (cond-predicate first)
(sequence->exp
(cond-actions first))
(expand-clauses
rest))))))
Expressões (como cond
) que escolhemos implementar como
transformações sintáticas são chamadas de
expressões derivadas.
Expressões let
também são expressões derivadas (veja
Exercício 4.6).217
Exercício 4.2: Louis Reasoner planeja reordenar as cláusulas
cond
emeval
para que a cláusula para aplicações de procedimento apareça antes da cláusula para atribuições. Ele argumenta que isso tornará o interpretador mais eficiente: Como os programas geralmente contêm mais aplicações do que atribuições, definições e assim por diante, seueval
modificado geralmente verificará menos cláusulas do que oeval
original antes de identificar o tipo de uma expressão.
- O que há de errado com o plano de Louis? (Dica: O que o avaliador de Louis fará com a expressão
(define x 3)
?)- Louis está chateado que seu plano não funcionou. Ele está disposto a fazer qualquer coisa para fazer seu avaliador reconhecer aplicações de procedimento antes de verificar a maioria dos outros tipos de expressões. Ajude-o mudando a sintaxe da linguagem avaliada para que as aplicações de procedimento comecem com
call
. Por exemplo, em vez de(factorial 3)
agora teremos que escrever(call factorial 3)
e em vez de(+ 1 2)
teremos que escrever(call + 1 2)
.
Exercício 4.3: Reescreva
eval
para que o despacho seja feito de forma dirigida por dados. Compare isso com o procedimento de diferenciação dirigido por dados de Exercício 2.73. (Você pode usar ocar
de uma expressão composta como o tipo da expressão, como é apropriado para a sintaxe implementada nesta seção.)
Exercício 4.4: Lembre-se das definições das formas especiais
and
eor
do Capítulo 1:
and
: As expressões são avaliadas da esquerda para a direita. Se qualquer expressão avaliar para falso, falso é retornado; quaisquer expressões restantes não são avaliadas. Se todas as expressões avaliarem para valores verdadeiros, o valor da última expressão é retornado. Se não houver expressões, então verdadeiro é retornado.or
: As expressões são avaliadas da esquerda para a direita. Se qualquer expressão avaliar para um valor verdadeiro, esse valor é retornado; quaisquer expressões restantes não são avaliadas. Se todas as expressões avaliarem para falso, ou se não houver expressões, então falso é retornado.Instale
and
eor
como novas formas especiais para o avaliador, definindo procedimentos de sintaxe apropriados e procedimentos de avaliaçãoeval-and
eeval-or
. Alternativamente, mostre como implementarand
eor
como expressões derivadas.
Exercício 4.5: Scheme permite uma sintaxe adicional para cláusulas
cond
,(⟨test⟩ => ⟨recipient⟩)
. Se⟨
test⟩
avaliar para um valor verdadeiro, então⟨
recipient⟩
é avaliado. Seu valor deve ser um procedimento de um argumento; este procedimento é então invocado com o valor de⟨
test⟩
, e o resultado é retornado como o valor da expressãocond
. Por exemplo(cond ((assoc 'b '((a 1) (b 2))) => cadr) (else false))
retorna 2. Modifique o tratamento de
cond
para que ele suporte essa sintaxe estendida.
Exercício 4.6: Expressões
let
são expressões derivadas, porque(let ((⟨var₁⟩ ⟨exp₁⟩) … (⟨varₙ⟩ ⟨expₙ⟩)) ⟨body⟩)
é equivalente a
((lambda (⟨var₁⟩ … ⟨varₙ⟩) ⟨body⟩) ⟨exp₁⟩ … ⟨expₙ⟩)
Implemente uma transformação sintática
let->combination
que reduza a avaliação de expressõeslet
à avaliação de combinações do tipo mostrado acima, e adicione a cláusula apropriada aeval
para lidar com expressõeslet
.
Exercício 4.7:
Let*
é semelhante alet
, exceto que as ligações das variáveis dolet*
são realizadas sequencialmente da esquerda para a direita, e cada ligação é feita em um ambiente no qual todas as ligações anteriores são visíveis. Por exemplo(let* ((x 3) (y (+ x 2)) (z (+ x y 5))) (* x z))
retorna 39. Explique como uma expressão
let*
pode ser reescrita como um conjunto de expressõeslet
aninhadas, e escreva um procedimentolet*->nested-lets
que realize essa transformação. Se já implementamoslet
(Exercício 4.6) e queremos estender o avaliador para lidar comlet*
, é suficiente adicionar uma cláusula aeval
cuja ação é(eval (let*->nested-lets exp) env)
ou devemos expandir explicitamente
let*
em termos de expressões não derivadas?
Exercício 4.8: “Named
let
” é uma variante delet
que tem a forma(let ⟨var⟩ ⟨bindings⟩ ⟨body⟩)
As
⟨
bindings⟩
e⟨
body⟩
são como nolet
comum, exceto que⟨
var⟩
é ligado dentro de⟨
body⟩
a um procedimento cujo corpo é⟨
body⟩
e cujos parâmetros são as variáveis nas⟨
bindings⟩
. Assim, pode-se executar repetidamente o⟨
body⟩
invocando o procedimento nomeado⟨
var⟩
. Por exemplo, o procedimento iterativo de Fibonacci (1.2.2) pode ser reescrito usando namedlet
da seguinte forma:(define (fib n) (let fib-iter ((a 1) (b 0) (count n)) (if (= count 0) b (fib-iter (+ a b) a (- count 1)))))
Modifique
let->combination
do Exercício 4.6 para também suportar namedlet
.
Exercício 4.9: Muitas linguagens suportam uma variedade de construções de iteração, como
do
,for
,while
euntil
. Em Scheme, processos iterativos podem ser expressos em termos de chamadas de procedimentos comuns, então construções de iteração especiais não fornecem ganho essencial em poder computacional. Por outro lado, tais construções são frequentemente convenientes. Projete algumas construções de iteração, dê exemplos de seu uso e mostre como implementá-las como expressões derivadas.
Exercício 4.10: Ao usar abstração de dados, pudemos escrever um procedimento
eval
que é independente da sintaxe específica da linguagem a ser avaliada. Para ilustrar isso, projete e implemente uma nova sintaxe para Scheme modificando os procedimentos nesta seção, sem alterareval
ouapply
.
Além de definir a sintaxe externa das expressões, a implementação do avaliador também deve definir as estruturas de dados que o avaliador manipula internamente, como parte da execução de um programa, como a representação de procedimentos e ambientes e a representação de verdadeiro e falso.
Para condicionais, aceitamos como verdadeiro qualquer coisa que não seja
o objeto explícito false
.
(define (true? x)
(not (eq? x false)))
(define (false? x)
(eq? x false))
Para lidar com primitivas, assumimos que temos disponíveis os seguintes procedimentos:
(apply-primitive-procedure ⟨proc⟩
⟨args⟩)
aplica a primitiva dada aos valores dos argumentos na lista
⟨
args⟩
e retorna o resultado da
aplicação.
(primitive-procedure? ⟨proc⟩)
testa se ⟨
proc⟩
é uma
primitiva.
Esses mecanismos para lidar com primitivas são descritos mais detalhadamente em 4.1.4.
Procedimentos compostos são construídos a partir de parâmetros, corpos
de procedimentos e ambientes usando o construtor
make-procedure
:
(define (make-procedure parameters body env)
(list 'procedure parameters body env))
(define (compound-procedure? p)
(tagged-list? p 'procedure))
(define (procedure-parameters p) (cadr p))
(define (procedure-body p) (caddr p))
(define (procedure-environment p) (cadddr p))
O avaliador precisa de operações para manipular ambientes. Como explicado em 3.2, um ambiente é uma sequência de quadros, onde cada quadro é uma tabela de ligações que associam variáveis a seus valores correspondentes. Usamos as seguintes operações para manipular ambientes:
(lookup-variable-value ⟨var⟩ ⟨env⟩)
retorna o valor que está ligado ao símbolo ⟨
var⟩
no ambiente ⟨
env⟩
, ou sinaliza um erro se a variável não
estiver ligada.
(extend-environment ⟨variables⟩ ⟨values⟩
⟨base-env⟩)
retorna um novo ambiente, consistindo de um novo quadro no qual os
símbolos na lista ⟨
variables⟩
são ligados aos elementos correspondentes na lista
⟨
values⟩
, onde o ambiente
envolvente é o ambiente ⟨
base-env⟩
.
(define-variable! ⟨var⟩ ⟨value⟩
⟨env⟩)
adiciona ao primeiro quadro no ambiente ⟨
env⟩
uma nova ligação que associa a variável
⟨
var⟩
ao valor ⟨
value⟩
.
(set-variable-value! ⟨var⟩ ⟨value⟩
⟨env⟩)
altera a ligação da variável ⟨
var⟩
no ambiente ⟨
env⟩
para que a variável agora esteja ligada ao valor
⟨
value⟩
, ou sinaliza um erro se
a variável não estiver ligada.
Para implementar essas operações, representamos um ambiente como uma
lista de quadros. O ambiente envolvente de um ambiente é o
cdr
da lista. O ambiente vazio é simplesmente a lista
vazia.
(define (enclosing-environment env) (cdr env))
(define (first-frame env) (car env))
(define the-empty-environment '())
Cada quadro de um ambiente é representado como um par de listas: uma lista das variáveis ligadas naquele quadro e uma lista dos valores associados.218
(define (make-frame variables values)
(cons variables values))
(define (frame-variables frame) (car frame))
(define (frame-values frame) (cdr frame))
(define (add-binding-to-frame! var val frame)
(set-car! frame (cons var (car frame)))
(set-cdr! frame (cons val (cdr frame))))
Para estender um ambiente por um novo quadro que associa variáveis a valores, fazemos um quadro consistindo da lista de variáveis e da lista de valores, e o adicionamos ao ambiente. Sinalizamos um erro se o número de variáveis não corresponder ao número de valores.
(define (extend-environment vars vals base-env)
(if (= (length vars) (length vals))
(cons (make-frame vars vals) base-env)
(if (< (length vars) (length vals))
(error "Too many arguments supplied"
vars
vals)
(error "Too few arguments supplied"
vars
vals))))
Para procurar uma variável em um ambiente, escaneamos a lista de variáveis no primeiro quadro. Se encontrarmos a variável desejada, retornamos o elemento correspondente na lista de valores. Se não encontrarmos a variável no quadro atual, procuramos no ambiente envolvente, e assim por diante. Se alcançarmos o ambiente vazio, sinalizamos um erro de “variável não ligada”.
(define (lookup-variable-value var env)
(define (env-loop env)
(define (scan vars vals)
(cond ((null? vars)
(env-loop
(enclosing-environment env)))
((eq? var (car vars))
(car vals))
(else (scan (cdr vars)
(cdr vals)))))
(if (eq? env the-empty-environment)
(error "Unbound variable" var)
(let ((frame (first-frame env)))
(scan (frame-variables frame)
(frame-values frame)))))
(env-loop env))
Para definir uma variável para um novo valor em um ambiente
especificado, procuramos pela variável, assim como em
lookup-variable-value
, e alteramos o valor correspondente
quando a encontramos.
(define (set-variable-value! var val env)
(define (env-loop env)
(define (scan vars vals)
(cond ((null? vars)
(env-loop
(enclosing-environment env)))
((eq? var (car vars))
(set-car! vals val))
(else (scan (cdr vars)
(cdr vals)))))
(if (eq? env the-empty-environment)
(error "Unbound variable: SET!" var)
(let ((frame (first-frame env)))
(scan (frame-variables frame)
(frame-values frame)))))
(env-loop env))
Para definir uma variável, procuramos no primeiro quadro por uma ligação
para a variável e alteramos a ligação se ela existir (assim como em
set-variable-value!
). Se tal ligação não existir,
adicionamos uma ao primeiro quadro.
(define (define-variable! var val env)
(let ((frame (first-frame env)))
(define (scan vars vals)
(cond ((null? vars)
(add-binding-to-frame!
var val frame))
((eq? var (car vars))
(set-car! vals val))
(else (scan (cdr vars)
(cdr vals)))))
(scan (frame-variables frame)
(frame-values frame))))
O método descrito aqui é apenas uma das muitas maneiras plausíveis de representar ambientes. Como usamos abstração de dados para isolar o restante do avaliador da escolha detalhada de representação, poderíamos mudar a representação do ambiente se quiséssemos. (Veja Exercício 4.11.) Em um sistema Lisp de qualidade de produção, a velocidade das operações de ambiente do avaliador—especialmente a de busca de variáveis—tem um grande impacto no desempenho do sistema. A representação descrita aqui, embora conceitualmente simples, não é eficiente e não seria normalmente usada em um sistema de produção.219
Exercício 4.11: Em vez de representar um quadro como um par de listas, podemos representar um quadro como uma lista de ligações, onde cada ligação é um par nome-valor. Reescreva as operações de ambiente para usar essa representação alternativa.
Exercício 4.12: Os procedimentos
define-variable!
,set-variable-value!
elookup-variable-value
podem ser expressos em termos de procedimentos mais abstratos para percorrer a estrutura do ambiente. Defina abstrações que capturem os padrões comuns e redefina os três procedimentos em termos dessas abstrações.
Exercício 4.13: Scheme nos permite criar novas ligações para variáveis por meio de
define
, mas não fornece uma maneira de se livrar das ligações. Implemente para o avaliador uma forma especialmake-unbound!
que remove a ligação de um determinado símbolo do ambiente no qual a expressãomake-unbound!
é avaliada. Este problema não está completamente especificado. Por exemplo, devemos remover apenas a ligação no primeiro quadro do ambiente? Complete a especificação e justifique quaisquer escolhas que fizer.
Dado o avaliador, temos em mãos uma descrição (expressa em Lisp) do processo pelo qual as expressões Lisp são avaliadas. Uma vantagem de expressar o avaliador como um programa é que podemos executar o programa. Isso nos dá, rodando dentro do Lisp, um modelo funcional de como o próprio Lisp avalia expressões. Isso pode servir como uma estrutura para experimentar com regras de avaliação, como faremos mais adiante neste capítulo.
Nosso programa avaliador reduz expressões, em última análise, à aplicação de procedimentos primitivos. Portanto, tudo o que precisamos para executar o avaliador é criar um mecanismo que chame o sistema Lisp subjacente para modelar a aplicação de procedimentos primitivos.
Deve haver uma ligação para cada nome de procedimento primitivo, de modo
que quando eval
avalia o operador de uma aplicação de uma
primitiva, ele encontrará um objeto para passar para apply
.
Assim, configuramos um ambiente global que associa objetos únicos aos
nomes dos procedimentos primitivos que podem aparecer nas expressões que
serão avaliadas. O ambiente global também inclui ligações para os
símbolos true
e false
, para que possam ser
usados como variáveis em expressões a serem avaliadas.
(define (setup-environment)
(let ((initial-env
(extend-environment
(primitive-procedure-names)
(primitive-procedure-objects)
the-empty-environment)))
(define-variable! 'true true initial-env)
(define-variable! 'false false initial-env)
initial-env))
(define the-global-environment
(setup-environment))
Não importa como representamos os objetos de procedimento primitivo,
desde que apply
possa identificá-los e aplicá-los usando os
procedimentos primitive-procedure?
e
apply-primitive-procedure
. Escolhemos representar um
procedimento primitivo como uma lista começando com o símbolo
primitive
e contendo um procedimento no Lisp subjacente que
implementa essa primitiva.
(define (primitive-procedure? proc)
(tagged-list? proc 'primitive))
(define (primitive-implementation proc)
(cadr proc))
Setup-environment
obterá os nomes e procedimentos de
implementação das primitivas de uma lista:220
(define primitive-procedures
(list (list 'car car)
(list 'cdr cdr)
(list 'cons cons)
(list 'null? null?)
⟨more primitives⟩ ))
(define (primitive-procedure-names)
(map car primitive-procedures))
(define (primitive-procedure-objects)
(map (lambda (proc)
(list 'primitive (cadr proc)))
primitive-procedures))
Para aplicar um procedimento primitivo, simplesmente aplicamos o procedimento de implementação aos argumentos, usando o sistema Lisp subjacente:221
(define (apply-primitive-procedure proc args)
(apply-in-underlying-scheme
(primitive-implementation proc) args))
Para conveniência em executar o avaliador metacircular, fornecemos um loop de driver que modela o loop de leitura-avaliação-impressão do sistema Lisp subjacente. Ele imprime um prompt, lê uma expressão de entrada, avalia essa expressão no ambiente global e imprime o resultado. Precedemos cada resultado impresso por um prompt de saída para distinguir o valor da expressão de outras saídas que podem ser impressas.222
(define input-prompt ";;; M-Eval input:")
(define output-prompt ";;; M-Eval value:")
(define (driver-loop)
(prompt-for-input input-prompt)
(let ((input (read)))
(let ((output
(eval input
the-global-environment)))
(announce-output output-prompt)
(user-print output)))
(driver-loop))
(define (prompt-for-input string)
(newline) (newline)
(display string) (newline))
(define (announce-output string)
(newline) (display string) (newline))
Usamos um procedimento de impressão especial, user-print
,
para evitar imprimir a parte do ambiente de um procedimento composto,
que pode ser uma lista muito longa (ou pode até conter ciclos).
(define (user-print object)
(if (compound-procedure? object)
(display
(list 'compound-procedure
(procedure-parameters object)
(procedure-body object)
'<procedure-env>))
(display object)))
Agora, tudo o que precisamos fazer para executar o avaliador é inicializar o ambiente global e iniciar o loop de driver. Aqui está uma interação de exemplo:
(define the-global-environment
(setup-environment))
(driver-loop)
;;; M-Eval input:
(define (append x y)
(if (null? x)
y
(cons (car x) (append (cdr x) y))))
;;; M-Eval value:
ok
;;; M-Eval input:
(append '(a b c) '(d e f))
;;; M-Eval value:
(a b c d e f)
Exercício 4.14: Eva Lu Ator e Louis Reasoner estão cada um experimentando com o avaliador metacircular. Eva digita a definição de
map
e executa alguns programas de teste que a usam. Eles funcionam bem. Louis, por outro lado, instalou a versão do sistema demap
como uma primitiva para o avaliador metacircular. Quando ele tenta, as coisas dão terrivelmente errado. Explique por que omap
de Louis falha, embora o de Eva funcione.
Ao pensar em um programa Lisp que avalia expressões Lisp, uma analogia pode ser útil. Uma visão operacional do significado de um programa é que um programa é uma descrição de uma máquina abstrata (talvez infinitamente grande). Por exemplo, considere o programa familiar para calcular fatoriais:
(define (factorial n)
(if (= n 1)
1
(* (factorial (- n 1)) n)))
Podemos considerar este programa como a descrição de uma máquina contendo partes que decrementam, multiplicam e testam igualdade, junto com um interruptor de duas posições e outra máquina fatorial. (A máquina fatorial é infinita porque contém outra máquina fatorial dentro dela.) Figura 4.2 é um diagrama de fluxo para a máquina fatorial, mostrando como as partes são conectadas.
Figura 4.2: O programa fatorial, visto como uma máquina abstrata.
De maneira semelhante, podemos considerar o avaliador como uma máquina
muito especial que recebe como entrada uma descrição de uma máquina.
Dada essa entrada, o avaliador se configura para emular a máquina
descrita. Por exemplo, se alimentarmos nosso avaliador com a definição
de factorial
, como mostrado em
Figura 4.3, o avaliador será capaz de
calcular fatoriais.
Figura 4.3: O avaliador emulando uma máquina fatorial.
Dessa perspectiva, nosso avaliador é visto como uma máquina universal. Ele imita outras máquinas quando estas são descritas como programas Lisp.223 Isso é impressionante. Tente imaginar um avaliador análogo para circuitos elétricos. Isso seria um circuito que recebe como entrada um sinal codificando os planos para algum outro circuito, como um filtro. Dada essa entrada, o avaliador de circuitos se comportaria como um filtro com a mesma descrição. Tal circuito elétrico universal é quase inimaginavelmente complexo. É notável que o programa avaliador seja um programa bastante simples.224
Outro aspecto impressionante do avaliador é que ele age como uma ponte
entre os objetos de dados que são manipulados por nossa linguagem de
programação e a própria linguagem de programação. Imagine que o programa
avaliador (implementado em Lisp) está rodando, e que um usuário está
digitando expressões para o avaliador e observando os resultados. Da
perspectiva do usuário, uma expressão de entrada como
(* x x)
é uma expressão na linguagem de programação, que o
avaliador deve executar. Da perspectiva do avaliador, no entanto, a
expressão é simplesmente uma lista (neste caso, uma lista de três
símbolos: *
, x
e x
) que deve ser
manipulada de acordo com um conjunto bem definido de regras.
Que os programas do usuário sejam os dados do avaliador não precisa ser
uma fonte de confusão. Na verdade, às vezes é conveniente ignorar essa
distinção e dar ao usuário a capacidade de avaliar explicitamente um
objeto de dados como uma expressão Lisp, disponibilizando
eval
para uso em programas. Muitos dialetos Lisp fornecem
um procedimento primitivo eval
que recebe como argumentos
uma expressão e um ambiente e avalia a expressão em relação ao
ambiente.225
Assim,
(eval '(* 5 5) user-initial-environment)
e
(eval (cons '* (list 5 5))
user-initial-environment)
ambos retornarão 25.226
208 Se nos concedermos a capacidade de aplicar primitivas, o que resta para nós implementarmos no avaliador? O trabalho do avaliador não é especificar as primitivas da linguagem, mas sim fornecer o tecido conectivo — os meios de combinação e os meios de abstração — que une uma coleção de primitivas para formar uma linguagem. Especificamente:
(+ 1 6)
, isso não é adequado
para lidar com (+ 1 (* 2 3))
. No que diz respeito ao
procedimento primitivo +
, seus argumentos devem ser
números, e ele falharia se passássemos a expressão
(* 2 3)
como argumento. Um papel importante do
avaliador é orquestrar a composição de procedimentos para que
(* 2 3)
seja reduzido a 6 antes de ser passado como
argumento para +
.
(+ x 1)
. Precisamos de um avaliador para rastrear
variáveis e obter seus valores antes de invocar os procedimentos
primitivos.
209
Poderíamos ter simplificado a cláusula application?
em
eval
usando map
(e estipulando que
operands
retorna uma lista) em vez de escrever um
procedimento explícito list-of-values
. Escolhemos não
usar map
aqui para enfatizar o fato de que o avaliador
pode ser implementado sem o uso de procedimentos de ordem superior
(e, portanto, poderia ser escrito em uma linguagem que não tem
procedimentos de ordem superior), mesmo que a linguagem que ele
suporta inclua procedimentos de ordem superior.
210
Neste caso, a linguagem que está sendo implementada e a linguagem de
implementação são a mesma. A contemplação do significado de
true?
aqui resulta em uma expansão da consciência sem o
abuso de substância.
211 Esta
implementação de define
ignora uma questão sutil no
tratamento de definições internas, embora funcione corretamente na
maioria dos casos. Veremos qual é o problema e como resolvê-lo em
4.1.6.
212 Como
dissemos quando introduzimos define
e
set!
, esses valores são dependentes da implementação em
Scheme — ou seja, o implementador pode escolher qual valor retornar.
213 Como
mencionado em 2.3.1, o
avaliador vê uma expressão entre aspas como uma lista começando com
quote
, mesmo que a expressão seja digitada com a marca
de aspas. Por exemplo, a expressão 'a
seria vista pelo
avaliador como (quote a)
. Veja
Exercício 2.55.
214 O
valor de uma expressão if
quando o predicado é falso e
não há alternativa é não especificado em Scheme; escolhemos aqui
torná-lo falso. Vamos suportar o uso das variáveis
true
e false
em expressões a serem
avaliadas, vinculando-as no ambiente global. Veja
4.1.4.
215 Esses seletores para uma lista de expressões — e os correspondentes para uma lista de operandos — não são destinados a uma abstração de dados. Eles são introduzidos como nomes mnemônicos para as operações básicas de lista, a fim de facilitar a compreensão do avaliador de controle explícito em 5.4.
216 O
valor de uma expressão cond
quando todos os predicados
são falsos e não há cláusula else
é não especificado em
Scheme; escolhemos aqui torná-lo falso.
217 Sistemas práticos de Lisp fornecem um mecanismo que permite ao usuário adicionar novas expressões derivadas e especificar sua implementação como transformações sintáticas sem modificar o avaliador. Tal transformação definida pelo usuário é chamada de macro. Embora seja fácil adicionar um mecanismo elementar para definir macros, a linguagem resultante tem problemas sutis de conflito de nomes. Houve muita pesquisa sobre mecanismos para definição de macros que não causam essas dificuldades. Veja, por exemplo, Kohlbecker 1986, Clinger e Rees 1991, e Hanson 1991.
218
Quadros não são realmente uma abstração de dados no seguinte código:
Set-variable-value!
e
define-variable!
usam set-car!
para
modificar diretamente os valores em um quadro. O propósito dos
procedimentos de quadro é tornar os procedimentos de manipulação de
ambiente fáceis de ler.
219 A desvantagem desta representação (bem como da variante em Exercício 4.11) é que o avaliador pode ter que pesquisar muitos quadros para encontrar a vinculação de uma determinada variável. (Essa abordagem é chamada de deep binding.) Uma maneira de evitar essa ineficiência é usar uma estratégia chamada lexical addressing, que será discutida em 5.5.6.
220
Qualquer procedimento definido no Lisp subjacente pode ser usado
como uma primitiva para o avaliador metacircular. O nome de uma
primitiva instalada no avaliador não precisa ser o mesmo que o nome
de sua implementação no Lisp subjacente; os nomes são os mesmos aqui
porque o avaliador metacircular implementa o próprio Scheme. Assim,
por exemplo, poderíamos colocar (list 'first car)
ou
(list 'square (lambda (x) (* x x)))
na lista de
primitive-procedures
.
221
Apply-in-underlying-scheme
é o procedimento
apply
que usamos nos capítulos anteriores. O
procedimento apply
do avaliador metacircular (4.1.1) modela o funcionamento dessa primitiva. Ter duas coisas
diferentes chamadas apply
leva a um problema técnico na
execução do avaliador metacircular, porque definir o
apply
do avaliador metacircular mascara a definição da
primitiva. Uma maneira de contornar isso é renomear o
apply
metacircular para evitar conflito com o nome do
procedimento primitivo. Assumimos, em vez disso, que salvamos uma
referência ao apply
subjacente fazendo
(define apply-in-underlying-scheme apply)
antes de definir o apply
metacircular. Isso nos permite
acessar a versão original de apply
sob um nome
diferente.
222 O
procedimento primitivo read
espera a entrada do usuário
e retorna a próxima expressão completa que é digitada. Por exemplo,
se o usuário digitar (+ 23 x)
,
read
retorna uma lista de três elementos contendo o
símbolo +
, o número 23 e o símbolo x
. Se o
usuário digitar 'x
, read
retorna uma lista
de dois elementos contendo o símbolo quote
e o símbolo
x
.
223 O fato de as máquinas serem descritas em Lisp é irrelevante. Se dermos ao nosso avaliador um programa Lisp que se comporta como um avaliador para outra linguagem, digamos C, o avaliador Lisp emulará o avaliador C, que por sua vez pode emular qualquer máquina descrita como um programa C. Da mesma forma, escrever um avaliador Lisp em C produz um programa C que pode executar qualquer programa Lisp. A ideia profunda aqui é que qualquer avaliador pode emular qualquer outro. Assim, a noção de “o que pode, em princípio, ser computado” (ignorando as praticidades de tempo e memória necessários) é independente da linguagem ou do computador, e em vez disso reflete uma noção subjacente de computabilidade. Isso foi demonstrado de forma clara pela primeira vez por Alan M. Turing (1912-1954), cujo artigo de 1936 estabeleceu as bases para a ciência da computação teórica. No artigo, Turing apresentou um modelo computacional simples — agora conhecido como máquina de Turing — e argumentou que qualquer “processo efetivo” pode ser formulado como um programa para tal máquina. (Esse argumento é conhecido como teses de Church-Turing.) Turing então implementou uma máquina universal, ou seja, uma máquina de Turing que se comporta como um avaliador para programas de máquina de Turing. Ele usou esse framework para demonstrar que existem problemas bem colocados que não podem ser computados por máquinas de Turing (veja Exercício 4.15), e, portanto, por implicação, não podem ser formulados como “processos efetivos.” Turing também fez contribuições fundamentais para a ciência da computação prática. Por exemplo, ele inventou a ideia de estruturar programas usando sub-rotinas de propósito geral. Veja Hodges 1983 para uma biografia de Turing.
224 Algumas pessoas acham contra-intuitivo que um avaliador, que é implementado por um procedimento relativamente simples, possa emular programas que são mais complexos que o próprio avaliador. A existência de uma máquina avaliadora universal é uma propriedade profunda e maravilhosa da computação. Teoria da recursão, um ramo da lógica matemática, está preocupado com os limites lógicos da computação. O belo livro de Douglas Hofstadter, Gödel, Escher, Bach, explora algumas dessas ideias (Hofstadter 1979).
225
Aviso: Esta primitiva eval
não é idêntica ao
procedimento eval
que implementamos em
4.1.1, porque ela usa ambientes
reais de Scheme em vez das estruturas de ambiente de
amostra que construímos em 4.1.3.
Esses ambientes reais não podem ser manipulados pelo usuário como
listas comuns; eles devem ser acessados via eval
ou
outras operações especiais. Da mesma forma, a primitiva
apply
que vimos anteriormente não é idêntica ao
apply
metacircular, porque ela usa procedimentos reais
de Scheme em vez dos objetos de procedimento que construímos em
4.1.3 e
4.1.4.
226 A
implementação do MIT do Scheme inclui
eval
, bem como um símbolo
user-initial-environment
que está vinculado ao ambiente
inicial no qual as expressões de entrada do usuário são avaliadas.
227
Embora tenhamos estipulado que halts?
recebe um objeto
de procedimento, observe que esse raciocínio ainda se aplica mesmo
se halts?
puder acessar o texto do procedimento e seu
ambiente. Este é o celebrado
Teorema da Parada de Turing, que deu o primeiro exemplo
claro de um
problema não computável, ou seja, uma tarefa bem colocada
que não pode ser realizada como um procedimento computacional.
228 O desejo de que os programas não dependam desse mecanismo de avaliação é a razão para a observação “a administração não é responsável” em Nota de rodapé 28 de Capítulo 1. Ao insistir que as definições internas venham primeiro e não se usem mutuamente enquanto as definições estão sendo avaliadas, o padrão IEEE para Scheme deixa aos implementadores alguma escolha no mecanismo usado para avaliar essas definições. A escolha de uma regra de avaliação em vez de outra aqui pode parecer uma questão pequena, afetando apenas a interpretação de programas “mal formados”. No entanto, veremos em 5.5.6 que mudar para um modelo de escopo simultâneo para definições internas evita algumas dificuldades desagradáveis que surgiriam de outra forma na implementação de um compilador.
229 O padrão IEEE para Scheme permite diferentes estratégias de implementação, especificando que cabe ao programador obedecer a essa restrição, e não à implementação para impô-la. Algumas implementações de Scheme, incluindo o MIT Scheme, usam a transformação mostrada acima. Assim, alguns programas que não obedecem a essa restrição de fato rodarão nessas implementações.
230 Os implementadores do MIT do Scheme apoiam Alyssa nos seguintes termos: Eva está, em princípio, correta — as definições devem ser consideradas simultâneas. Mas parece difícil implementar um mecanismo geral e eficiente que faça o que Eva exige. Na ausência de tal mecanismo, é melhor gerar um erro nos casos difíceis de definições simultâneas (a noção de Alyssa) do que produzir uma resposta incorreta (como Ben teria).
231 Este
exemplo ilustra um truque de programação para formular procedimentos
recursivos sem usar define
. O truque mais geral desse
tipo é o
operador, que pode ser usado
para dar uma implementação de recursão “pura λ-cálculo”. (Veja
Stoy 1977 para detalhes
sobre o λ-cálculo, e
Gabriel 1988 para uma
exposição do operador
em Scheme.)
232 Esta técnica é uma parte integral do processo de compilação, que discutiremos em Capítulo 5. Jonathan Rees escreveu um interpretador Scheme assim por volta de 1982 para o projeto T (Rees e Adams 1982). Marc Feeley (1986) (veja também Feeley e Lapalme 1987) inventou independentemente essa técnica em sua tese de mestrado.
233 Há, no entanto, uma parte importante da busca de variáveis que pode ser feita como parte da análise sintática. Como mostraremos em 5.5.6, pode-se determinar a posição na estrutura do ambiente onde o valor da variável será encontrado, eliminando a necessidade de escanear o ambiente para a entrada que corresponde à variável.
234 Veja Exercício 4.23 para algumas percepções sobre o processamento de sequências.