4.1O Avaliador Metacircular

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:

  1. Para avaliar uma combinação (uma expressão composta que não seja uma forma especial), avalie as subexpressões e então aplique o valor da subexpressão do operador aos valores das subexpressões dos operandos.
  2. Para aplicar um procedimento composto a um conjunto de argumentos, avalie o corpo do procedimento em um novo ambiente. Para construir esse ambiente, estenda a parte do ambiente do objeto de procedimento por um quadro no qual os parâmetros formais do procedimento são vinculados aos argumentos aos quais o procedimento é aplicado.

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).

SVG

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.

4.1.1O Núcleo do Avaliador

O processo de avaliação pode ser descrito como a interação entre dois procedimentos: eval e apply.

Eval

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

Formas especiais

Combinações

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

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))))
Argumentos de procedimento

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))))
Condicionais

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

Sequências

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))))
Atribuições e definições

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 em list-of-values são avaliados da esquerda para a direita, então list-of-values avaliará operandos da esquerda para a direita; e se os argumentos para cons são avaliados da direita para a esquerda, então list-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 de list-of-values que avalia operandos da direita para a esquerda.

4.1.2Representando Expressões

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:

Expressões derivadas

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 em eval 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, seu eval modificado geralmente verificará menos cláusulas do que o eval original antes de identificar o tipo de uma expressão.

  1. 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)?)
  2. 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 o car 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 e or do Capítulo 1:

Instale and e or como novas formas especiais para o avaliador, definindo procedimentos de sintaxe apropriados e procedimentos de avaliação eval-and e eval-or. Alternativamente, mostre como implementar and e or 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ão cond. 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ões let à avaliação de combinações do tipo mostrado acima, e adicione a cláusula apropriada a eval para lidar com expressões let.

Exercício 4.7: Let* é semelhante a let, exceto que as ligações das variáveis do let* 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ões let aninhadas, e escreva um procedimento let*->nested-lets que realize essa transformação. Se já implementamos let (Exercício 4.6) e queremos estender o avaliador para lidar com let*, é suficiente adicionar uma cláusula a eval 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 de let que tem a forma

(let ⟨var⟩ ⟨bindings⟩ ⟨body⟩)

As bindings e body são como no let 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 named let 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 named let.

Exercício 4.9: Muitas linguagens suportam uma variedade de construções de iteração, como do, for, while e until. 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 alterar eval ou apply.

4.1.3Estruturas de Dados do Avaliador

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.

Teste de predicados

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))
Representação de procedimentos

Para lidar com primitivas, assumimos que temos disponíveis os seguintes procedimentos:

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))
Operações sobre Ambientes

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:

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! e lookup-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 especial make-unbound! que remove a ligação de um determinado símbolo do ambiente no qual a expressão make-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.

4.1.4Executando o Avaliador como um Programa

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 de map como uma primitiva para o avaliador metacircular. Quando ele tenta, as coisas dão terrivelmente errado. Explique por que o map de Louis falha, embora o de Eva funcione.

4.1.5Dados como Programas

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.

SVG

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.

SVG

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

Notas de rodapé

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:

  • O avaliador nos permite lidar com expressões aninhadas. Por exemplo, embora simplesmente aplicar primitivas seja suficiente para avaliar a expressão (+ 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 +.
  • O avaliador nos permite usar variáveis. Por exemplo, o procedimento primitivo para adição não tem como lidar com expressões como (+ x 1). Precisamos de um avaliador para rastrear variáveis e obter seus valores antes de invocar os procedimentos primitivos.
  • O avaliador nos permite definir procedimentos compostos. Isso envolve rastrear definições de procedimentos, saber como usar essas definições na avaliação de expressões e fornecer um mecanismo que permita que os procedimentos aceitem argumentos.
  • O avaliador fornece as formas especiais, que devem ser avaliadas de maneira diferente das chamadas de procedimento.

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 Y 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 Y 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.