5.5
5.5 Compilação
O avaliador de controle explícito da seção 5.4 é uma máquina de registradores cujo controlador interpreta programas JavaScript. Nesta seção veremos como executar programas JavaScript em uma máquina de registradores cujo controlador não é um interpretador JavaScript.
O avaliador de controle explícito é universal—ele pode executar qualquer processo computacional que possa ser descrito em JavaScript. O controlador do avaliador orquestra o uso de seus caminhos de dados para realizar a computação desejada. Assim, os caminhos de dados do avaliador são universais: eles são suficientes para realizar qualquer computação que desejemos, dado um controlador apropriado.1
Computadores de uso geral comerciais são máquinas de registradores organizadas em torno de uma coleção de registradores e operações que constituem um conjunto universal de caminhos de dados eficiente e conveniente. O controlador de uma máquina de uso geral é um interpretador para uma linguagem de máquina de registradores como a que temos usado. Esta linguagem é chamada de linguagem nativa da máquina, ou simplesmente linguagem de máquina. Programas escritos em linguagem de máquina são sequências de instruções que usam os caminhos de dados da máquina. Por exemplo, a sequência de instruções do avaliador de controle explícito pode ser pensada como um programa em linguagem de máquina para um computador de uso geral ao invés de como o controlador para uma máquina interpretadora especializada.
Existem duas estratégias comuns para preencher a lacuna entre linguagens de nível superior e linguagens de máquina de registradores. O avaliador de controle explícito ilustra a estratégia de interpretação. Um interpretador escrito na linguagem nativa de uma máquina configura a máquina para executar programas escritos em uma linguagem (chamada de linguagem fonte) que pode diferir da linguagem nativa da máquina que realiza a avaliação. As funções primitivas da linguagem fonte são implementadas como uma biblioteca de sub-rotinas escritas na linguagem nativa da máquina dada. Um programa a ser interpretado (chamado de programa fonte) é representado como uma estrutura de dados. O interpretador percorre esta estrutura de dados, analisando o programa fonte. Ao fazê-lo, ele simula o comportamento pretendido do programa fonte chamando sub-rotinas primitivas apropriadas da biblioteca.
Nesta seção, exploramos a estratégia alternativa de compilação. Um compilador para uma determinada linguagem fonte e máquina traduz um programa fonte em um programa equivalente (chamado de programa objeto) escrito na linguagem nativa da máquina. O compilador que implementamos nesta seção traduz programas escritos em JavaScript em sequências de instruções a serem executadas usando os caminhos de dados da máquina avaliadora de controle explícito.2
Comparada com a interpretação, a compilação pode fornecer um grande aumento na eficiência da execução de programas, como explicaremos abaixo na visão geral do compilador. Por outro lado, um interpretador fornece um ambiente mais poderoso para desenvolvimento e depuração interativa de programas, porque o programa fonte sendo executado está disponível em tempo de execução para ser examinado e modificado. Além disso, como toda a biblioteca de primitivos está presente, novos programas podem ser construídos e adicionados ao sistema durante a depuração.
Em vista das vantagens complementares da compilação e interpretação, ambientes modernos de desenvolvimento de programas seguem uma estratégia mista. Estes sistemas são geralmente organizados de modo que funções interpretadas e funções compiladas possam se chamar mutuamente. Isso permite que um programador compile aquelas partes de um programa que se supõe estarem depuradas, ganhando assim a vantagem de eficiência da compilação, enquanto mantém o modo interpretativo de execução para aquelas partes do programa que estão em fluxo de desenvolvimento e depuração interativos.3 Na seção 5.5.7, depois de implementarmos o compilador, mostraremos como interfaceá-lo com nosso interpretador para produzir um sistema integrado interpretador-compilador.
Uma visão geral do compilador
Nosso compilador é muito parecido com nosso interpretador, tanto em sua estrutura quanto na função que executa. Consequentemente, os mecanismos usados pelo compilador para analisar componentes serão semelhantes àqueles usados pelo interpretador. Além disso, para facilitar a interface entre código compilado e interpretado, projetaremos o compilador para gerar código que obedece às mesmas convenções de uso de registradores que o interpretador: o ambiente será mantido no registrador env, listas de argumentos serão acumuladas em argl, uma função a ser aplicada estará em fun, funções retornarão suas respostas em val, e o local para o qual uma função deve retornar será mantido em continue. Em geral, o compilador traduz um programa fonte em um programa objeto que executa essencialmente as mesmas operações de registrador que o interpretador executaria ao avaliar o mesmo programa fonte.
Esta descrição sugere uma estratégia para implementar um compilador rudimentar: percorremos o componente da mesma forma que o interpretador faz. Quando encontramos uma instrução de registrador que o interpretador executaria ao avaliar o componente, não executamos a instrução, mas em vez disso a acumulamos em uma sequência. A sequência resultante de instruções será o código objeto. Observe a vantagem de eficiência da compilação sobre a interpretação. Cada vez que o interpretador avalia um componente—por exemplo, f(96, 22)—ele realiza o trabalho de classificar o componente (descobrindo que isto é uma aplicação de função) e testar o fim da lista de expressões de argumento (descobrindo que há duas expressões de argumento). Com um compilador, o componente é analisado apenas uma vez, quando a sequência de instruções é gerada em tempo de compilação. O código objeto produzido pelo compilador contém apenas as instruções que avaliam a expressão de função e as duas expressões de argumento, montam a lista de argumentos e aplicam a função (em fun) aos argumentos (em argl).
Este é o mesmo tipo de otimização que implementamos no avaliador analisador da seção 4.1.7. Mas há outras oportunidades para ganhar eficiência em código compilado. À medida que o interpretador executa, ele segue um processo que deve ser aplicável a qualquer componente na linguagem. Em contraste, um dado segmento de código compilado é destinado a executar algum componente particular. Isso pode fazer uma grande diferença, por exemplo no uso da pilha para salvar registradores. Quando o interpretador avalia um componente, ele deve estar preparado para qualquer contingência. Antes de avaliar um subcomponente, o interpretador salva todos os registradores que serão necessários mais tarde, porque o subcomponente pode requerer uma avaliação arbitrária. Um compilador, por outro lado, pode explorar a estrutura do componente particular que está processando para gerar código que evita operações desnecessárias de pilha.
Como um exemplo específico, considere a aplicação f(96, 22). Antes que o interpretador avalie a expressão de função da aplicação, ele se prepara para esta avaliação salvando os registradores contendo as expressões de argumento e o ambiente, cujos valores serão necessários mais tarde. O interpretador então avalia a expressão de função para obter o resultado em val, restaura os registradores salvos e finalmente move o resultado de val para fun. No entanto, na expressão particular com a qual estamos lidando, a expressão de função é o nome f, cuja avaliação é realizada pela operação de máquina lookup_symbol_value, que não altera nenhum registrador. O compilador que implementamos nesta seção tirará vantagem deste fato e gerará código que avalia a expressão de função usando a instrução
assign("fun",
list(op("lookup_symbol_value"), constant("f"), reg("env")))
onde o argumento para lookup_symbol_value é extraído em tempo de compilação da representação do analisador de f(96, 22). Este código não apenas evita os salvamentos e restaurações desnecessários, mas também atribui o valor da busca diretamente a fun, enquanto o interpretador obteria o resultado em val e então moveria isso para fun.
Um compilador também pode otimizar o acesso ao ambiente. Tendo analisado o código, o compilador pode saber em qual quadro o valor de um nome particular estará localizado e acessar esse quadro diretamente, em vez de realizar a busca lookup_symbol_value. Discutiremos como implementar tal endereçamento léxico na seção 5.5.6. Até então, no entanto, focaremos no tipo de otimizações de registrador e pilha descritas acima. Existem muitas outras otimizações que podem ser realizadas por um compilador, como codificar operações primitivas "em linha" em vez de usar um mecanismo geral de apply (veja exercício 5.38); mas não enfatizaremos isso aqui. Nosso objetivo principal nesta seção é ilustrar o processo de compilação em um contexto simplificado (mas ainda interessante).