🎨 Interpretação: Execução Direta da AST
Agora que você compreende profundamente as diferenças filosóficas entre interpretação e compilação, é hora de mergulhar na implementação prática de um interpretador. Esta é uma das experiências mais gratificantes na construção de compiladores - ver sua AST cuidadosamente construída ganhar vida e produzir resultados reais!
A beleza da interpretação está em sua simplicidade conceitual. Você não precisa entender arquiteturas de processadores, convenções de chamada, ou formatos de arquivos executáveis. Tudo o que você precisa é percorrer sua AST de forma sistemática, executando as ações apropriadas para cada tipo de nó. É elegante, direto, e incrivelmente educativo.
Arquitetura de Interpretadores
Interpretadores operam através de um princípio fundamental: percorrer recursivamente a AST, executando ações apropriadas para cada tipo de nó encontrado. A estrutura fundamental é um avaliador que implementa o padrão visitor sobre a AST, com métodos específicos para avaliar cada tipo de construção linguística.
Mas o que exatamente isso significa na prática? Vamos desmontar esta arquitetura em componentes compreensíveis.
🗝️ Componentes Fundamentais de um Interpretador
Um interpretador completo consiste em três componentes principais que trabalham em harmonia perfeita para executar programas:
Ambiente de Execução é onde vivem os valores das variáveis durante a execução do programa. Pense nele como um grande dicionário que mapeia nomes de variáveis para seus valores atuais. Mas há um detalhe importante: programas têm escopos aninhados (funções dentro de funções, blocos dentro de blocos), então precisamos de uma pilha de ambientes, não apenas um único dicionário global.
Cada frame nesta pilha corresponde a um escopo léxico - global, de função, ou de bloco. Quando uma variável é referenciada, o interpretador busca seu valor começando no frame mais interno (topo da pilha) e descendo até encontrar a variável ou esgotar todos os frames. Isso implementa naturalmente as regras de shadowing e escopo que você estudou em análise semântica.
Avaliador de Expressões é o coração matemático do interpretador. Ele computa valores de expressões navegando recursivamente pela subárvore de expressão. A beleza está na recursão natural: para avaliar uma expressão binária como x + 10, primeiro avalie recursivamente o operando esquerdo (x), depois o direito (10), e então aplique o operador (+) aos valores resultantes.
Expressões literais são triviais - apenas retornam seus valores diretos. Expressões de variável consultam o ambiente. Expressões mais complexas como chamadas de função criam novo frame de ambiente, avaliam argumentos, e executam recursivamente o corpo da função. Esta abordagem recursiva reflete perfeitamente a estrutura hierárquica de expressões.
Executor de Comandos implementa os efeitos colaterais de diferentes tipos de comandos. Enquanto o avaliador de expressões computa valores “puros”, o executor de comandos modifica estado - atualiza variáveis, imprime na tela, altera fluxo de controle.
Comandos de atribuição avaliam a expressão do lado direito usando o avaliador e depois atualizam o ambiente com o novo valor. Comandos de impressão avaliam expressão e enviam resultado para dispositivo de saída. Comandos de controle de fluxo como condicionais e loops avaliam condições booleanas e executam recursivamente os blocos apropriados. Esta separação clara entre avaliação e execução mantém o código organizado e compreensível.
Vamos tornar estes conceitos absolutamente concretos com um exemplo passo-a-passo. Considere este simples fragmento de código Didágica:
Guarde Inteiro x como 5;
Guarde Inteiro y como x + 3;
Escreva y;
Aqui está exatamente como o interpretador processa isso, passo por passo:
✅ Execução Passo-a-Passo do Interpretador
Passo 1: Inicialização
O interpretador começa com ambiente vazio:
Ambiente: {}
Passo 2: Executar primeiro comando (declaração de x)
- Identifica tipo de nó: DeclaraçãoDeVariável
- Extrai nome da variável: “x”
- Chama avaliador para expressão inicializadora (literal 5)
- Avaliador retorna valor: 5
- Atualiza ambiente:
{"x": 5}
Passo 3: Executar segundo comando (declaração de y)
- Identifica tipo de nó: DeclaraçãoDeVariável
- Extrai nome da variável: “y”
- Chama avaliador para expressão inicializadora (x + 3)
- Avaliador identifica expressão binária:
- Avalia operando esquerdo (referência a x): consulta ambiente, retorna 5
- Avalia operando direito (literal 3): retorna 3
- Aplica operador (+): 5 + 3 = 8
- Atualiza ambiente:
{"x": 5, "y": 8}
Passo 4: Executar terceiro comando (impressão)
- Identifica tipo de nó: ComandoDeImpressão
- Chama avaliador para expressão (referência a y)
- Avaliador consulta ambiente, retorna 8
- Envia valor 8 para dispositivo de saída
- Console exibe:
8
Estado Final:
Ambiente: {"x": 5, "y": 8}
Console: "8"
Viu como funciona? Cada passo é mecânico e determinístico. O interpretador simplesmente segue a estrutura da AST, aplicando regras claras para cada tipo de nó. Não há magia - apenas transformação sistemática de estrutura de dados em comportamento observável.
Implementação Concreta: Avaliador de Expressões
Vamos ver código real que implementa este avaliador. Começaremos com algo simples mas completo em Dart, já que esta linguagem oferece clareza pedagógica excepcional:
// Classe base para todos os valores em runtime
abstract class Valor {
T aceitar<T>(VisitadorDeValor<T> visitador);
}
class ValorInteiro extends Valor {
final int valor;
ValorInteiro(this.valor);
@override
T aceitar<T>(VisitadorDeValor<T> visitador) {
return visitador.visitarValorInteiro(this);
}
}
class ValorReal extends Valor {
final double valor;
ValorReal(this.valor);
@override
T aceitar<T>(VisitadorDeValor<T> visitador) {
return visitador.visitarValorReal(this);
}
}
class ValorBooleano extends Valor {
final bool valor;
ValorBooleano(this.valor);
@override
T aceitar<T>(VisitadorDeValor<T> visitador) {
return visitador.visitarValorBooleano(this);
}
}
// Ambiente de execução com suporte a escopos aninhados
class Ambiente {
final Map<String, Valor> _variaveis = {};
final Ambiente? _pai;
Ambiente([this._pai]);
// Define novo valor no escopo atual
void definir(String nome, Valor valor) {
_variaveis[nome] = valor;
}
// Busca valor começando do escopo atual e subindo
Valor obter(String nome) {
if (_variaveis.containsKey(nome)) {
return _variaveis[nome]!;
}
if (_pai != null) {
return _pai!.obter(nome);
}
throw Exception('Variável não declarada: $nome');
}
// Atualiza valor de variável existente
void atribuir(String nome, Valor valor) {
if (_variaveis.containsKey(nome)) {
_variaveis[nome] = valor;
return;
}
if (_pai != null) {
_pai!.atribuir(nome, valor);
return;
}
throw Exception('Variável não declarada: $nome');
}
}
// Avaliador de expressões
class AvaliadorDeExpressoes {
final Ambiente _ambiente;
AvaliadorDeExpressoes(this._ambiente);
Valor avaliar(NoExpressao expressao) {
if (expressao is ExpressaoLiteral) {
return _avaliarLiteral(expressao);
} else if (expressao is ExpressaoVariavel) {
return _avaliarVariavel(expressao);
} else if (expressao is ExpressaoBinaria) {
return _avaliarBinaria(expressao);
} else if (expressao is ExpressaoUnaria) {
return _avaliarUnaria(expressao);
}
throw Exception('Tipo de expressão desconhecido');
}
Valor _avaliarLiteral(ExpressaoLiteral expr) {
// Literais simplesmente retornam seus valores
if (expr.valor is int) {
return ValorInteiro(expr.valor);
} else if (expr.valor is double) {
return ValorReal(expr.valor);
} else if (expr.valor is bool) {
return ValorBooleano(expr.valor);
}
throw Exception('Tipo de literal desconhecido');
}
Valor _avaliarVariavel(ExpressaoVariavel expr) {
// Variáveis consultam o ambiente
return _ambiente.obter(expr.nome);
}
Valor _avaliarBinaria(ExpressaoBinaria expr) {
// Avalia recursivamente os operandos
Valor esquerdo = avaliar(expr.esquerdo);
Valor direito = avaliar(expr.direito);
// Aplica operador baseado nos tipos
if (esquerdo is ValorInteiro && direito is ValorInteiro) {
return _aplicarOperadorInteiro(
esquerdo.valor,
direito.valor,
expr.operador
);
} else if (esquerdo is ValorReal || direito is ValorReal) {
double e = (esquerdo is ValorInteiro)
? esquerdo.valor.toDouble()
: (esquerdo as ValorReal).valor;
double d = (direito is ValorInteiro)
? direito.valor.toDouble()
: (direito as ValorReal).valor;
return _aplicarOperadorReal(e, d, expr.operador);
}
throw Exception('Tipos incompatíveis em operação binária');
}
Valor _aplicarOperadorInteiro(int e, int d, String op) {
switch (op) {
case '+':
return ValorInteiro(e + d);
case '-':
return ValorInteiro(e - d);
case '*':
return ValorInteiro(e * d);
case '/':
return ValorInteiro(e ~/ d); // Divisão inteira
case '%':
return ValorInteiro(e % d);
case '>':
return ValorBooleano(e > d);
case '<':
return ValorBooleano(e < d);
case '>=':
return ValorBooleano(e >= d);
case '<=':
return ValorBooleano(e <= d);
case '==':
return ValorBooleano(e == d);
case '!=':
return ValorBooleano(e != d);
default:
throw Exception('Operador desconhecido: $op');
}
}
Valor _aplicarOperadorReal(double e, double d, String op) {
switch (op) {
case '+':
return ValorReal(e + d);
case '-':
return ValorReal(e - d);
case '*':
return ValorReal(e * d);
case '/':
return ValorReal(e / d);
case '>':
return ValorBooleano(e > d);
case '<':
return ValorBooleano(e < d);
case '>=':
return ValorBooleano(e >= d);
case '<=':
return ValorBooleano(e <= d);
case '==':
return ValorBooleano(e == d);
case '!=':
return ValorBooleano(e != d);
default:
throw Exception('Operador desconhecido: $op');
}
}
Valor _avaliarUnaria(ExpressaoUnaria expr) {
Valor operando = avaliar(expr.operando);
if (expr.operador == '-') {
if (operando is ValorInteiro) {
return ValorInteiro(-operando.valor);
} else if (operando is ValorReal) {
return ValorReal(-operando.valor);
}
} else if (expr.operador == '!') {
if (operando is ValorBooleano) {
return ValorBooleano(!operando.valor);
}
}
throw Exception('Operador unário inválido');
}
}#include <memory>
#include <string>
#include <unordered_map>
#include <variant>
#include <stdexcept>
// Valor em runtime usando variant do C++17
using ValorRuntime = std::variant<int64_t, double, bool, std::string>;
// Ambiente de execução com escopos aninhados
class Ambiente {
private:
std::unordered_map<std::string, ValorRuntime> variaveis;
std::shared_ptr<Ambiente> pai;
public:
explicit Ambiente(std::shared_ptr<Ambiente> pai = nullptr)
: pai(pai) {}
void definir(const std::string& nome, const ValorRuntime& valor) {
variaveis[nome] = valor;
}
ValorRuntime obter(const std::string& nome) const {
auto it = variaveis.find(nome);
if (it != variaveis.end()) {
return it->second;
}
if (pai) {
return pai->obter(nome);
}
throw std::runtime_error("Variável não declarada: " + nome);
}
void atribuir(const std::string& nome, const ValorRuntime& valor) {
auto it = variaveis.find(nome);
if (it != variaveis.end()) {
it->second = valor;
return;
}
if (pai) {
pai->atribuir(nome, valor);
return;
}
throw std::runtime_error("Variável não declarada: " + nome);
}
};
// Avaliador de expressões
class AvaliadorDeExpressoes {
private:
std::shared_ptr<Ambiente> ambiente;
ValorRuntime aplicarOperadorBinario(
const ValorRuntime& esquerdo,
const ValorRuntime& direito,
const std::string& operador) {
// Tenta operação com inteiros
if (std::holds_alternative<int64_t>(esquerdo) &&
std::holds_alternative<int64_t>(direito)) {
int64_t e = std::get<int64_t>(esquerdo);
int64_t d = std::get<int64_t>(direito);
if (operador == "+") return e + d;
if (operador == "-") return e - d;
if (operador == "*") return e * d;
if (operador == "/") return e / d;
if (operador == "%") return e % d;
if (operador == ">") return e > d;
if (operador == "<") return e < d;
if (operador == ">=") return e >= d;
if (operador == "<=") return e <= d;
if (operador == "==") return e == d;
if (operador == "!=") return e != d;
}
// Promoção para real se necessário
double e = std::holds_alternative<int64_t>(esquerdo)
? static_cast<double>(std::get<int64_t>(esquerdo))
: std::get<double>(esquerdo);
double d = std::holds_alternative<int64_t>(direito)
? static_cast<double>(std::get<int64_t>(direito))
: std::get<double>(direito);
if (operador == "+") return e + d;
if (operador == "-") return e - d;
if (operador == "*") return e * d;
if (operador == "/") return e / d;
if (operador == ">") return e > d;
if (operador == "<") return e < d;
if (operador == ">=") return e >= d;
if (operador == "<=") return e <= d;
if (operador == "==") return e == d;
if (operador == "!=") return e != d;
throw std::runtime_error("Operador desconhecido: " + operador);
}
public:
explicit AvaliadorDeExpressoes(std::shared_ptr<Ambiente> amb)
: ambiente(amb) {}
ValorRuntime avaliar(const NoExpressao* expressao) {
// Implementação similar ao Dart
// Despacha baseado no tipo de nó
return expressao->aceitar(this);
}
};💡 Observe os Padrões Comuns
Independente da linguagem de implementação, os padrões fundamentais são os mesmos:
- Recursão estrutural: A avaliação segue a estrutura recursiva da AST
- Ambiente hierárquico: Escopos são organizados em pilha
- Despacho por tipo: Cada tipo de nó tem tratamento específico
- Valores tipados: Representações em runtime carregam informação de tipo
Estes padrões são universais na implementação de interpretadores. Uma vez que você os compreende, pode implementar interpretadores em qualquer linguagem.
Gerenciamento de Ambiente e Escopos
Um dos aspectos mais sutis e importantes da interpretação é gerenciar corretamente ambientes de execução para escopos léxicos aninhados. Variáveis declaradas em diferentes escopos devem ser visíveis apenas onde apropriado, e variáveis locais podem “sombrear” (shadow) globais sem causar conflitos ou confusão.
A implementação típica usa uma pilha de dicionários (ou hash maps, ou tabelas). Cada dicionário representa um frame de escopo, mapeando nomes de variáveis para seus valores atuais. Quando um novo escopo é entrado - seja porque uma função foi chamada, seja porque um bloco foi iniciado - um novo frame é empurrado para o topo da pilha. Quando o escopo termina, o frame é removido da pilha.
Para buscar o valor de uma variável, o interpretador começa no frame do topo (o escopo mais interno, mais recente) e procura o nome. Se encontrado, retorna o valor imediatamente. Se não encontrado, tenta o frame logo abaixo na pilha. Continue descendo até encontrar a variável ou esgotar toda a pilha, nesse caso reportando erro de variável não declarada.
Vamos ver um exemplo concreto que demonstra todas estas nuances:
✅ Exemplo Completo de Gerenciamento de Escopos
Considere este código Didágica com escopos aninhados:
Guarde Inteiro x como 10; // Escopo global
Guarde Inteiro y como 20; // Escopo global
Funcao calcular()
Guarde Inteiro x como 5; // Escopo local - shadow de x global
Guarde Inteiro z como x + y; // x local (5), y global (20)
Escreva z; // Imprime 25
retorne z;
Fim
Guarde Inteiro resultado como calcular();
Escreva x; // Imprime 10 (x global)
Escreva resultado; // Imprime 25
Vamos acompanhar a evolução da pilha de ambientes:
Estado 1 - Após declarações globais:
┌─────────────────────┐
│ Frame Global │
│ x: 10 │
│ y: 20 │
└─────────────────────┘
Estado 2 - Entrando em calcular():
Um novo frame é criado para o escopo da função:
┌─────────────────────┐ ← Topo da pilha
│ Frame calcular() │
│ (vazio inicialmente)│
└─────────────────────┘
┌─────────────────────┐
│ Frame Global │
│ x: 10 │
│ y: 20 │
└─────────────────────┘
Estado 3 - Após Guarde Inteiro x como 5;:
┌─────────────────────┐ ← Topo da pilha
│ Frame calcular() │
│ x: 5 │ // Shadow de x global!
└─────────────────────┘
┌─────────────────────┐
│ Frame Global │
│ x: 10 │ // Ainda existe, apenas oculto
│ y: 20 │
└─────────────────────┘
Estado 4 - Avaliando x + y:
- Busca
x: encontrado no frame do topo (valor 5) - Busca
y: não encontrado no topo, desce para frame global (valor 20) - Resultado: 5 + 20 = 25
┌─────────────────────┐ ← Topo da pilha
│ Frame calcular() │
│ x: 5 │
│ z: 25 │
└─────────────────────┘
┌─────────────────────┐
│ Frame Global │
│ x: 10 │
│ y: 20 │
└─────────────────────┘
Estado 5 - Após retorno de calcular():
O frame da função é removido:
┌─────────────────────┐
│ Frame Global │
│ x: 10 │ // x global volta a ser visível!
│ y: 20 │
│ resultado: 25 │
└─────────────────────┘
Esta abordagem de pilha de frames implementa naturalmente shadowing e garante que variáveis locais não interferem com globais. É elegante conceitualmente, mas requer disciplina cuidadosa na implementação para evitar vazamentos de memória ou corrupção de estado.
Implementação de Controle de Fluxo
Comandos de controle de fluxo como condicionais, loops e retornos de função apresentam desafios interessantes para interpretadores. A abordagem recursiva padrão funciona bem para estruturas lineares de comandos sequenciais, mas controle de fluxo não-local requer mecanismos especiais.
Condicionais (if/else) são relativamente diretos conceitualmente. O interpretador avalia a expressão de condição. Se o resultado é verdadeiro, executa recursivamente o bloco then. Se falso, executa o bloco else (se existir). O desafio prático é garantir que apenas um dos caminhos seja executado e que quaisquer valores de variáveis modificados em um caminho sejam visíveis após o condicional terminar.
Aqui está uma implementação concreta:
void executarCondicional(NoCondicional no, Ambiente ambiente) {
// Avalia a condição
Valor condicao = avaliador.avaliar(no.condicao);
if (condicao is! ValorBooleano) {
throw Exception('Condição deve ser booleana');
}
if (condicao.valor) {
// Executa bloco then
executarBloco(no.blocoThen, ambiente);
} else if (no.blocoElse != null) {
// Executa bloco else se existir
executarBloco(no.blocoElse!, ambiente);
}
// Continue execução normal após o condicional
}Loops (while, for) são executados através de iteração explícita. Para um loop while, continuamos avaliando a condição e executando o corpo enquanto a condição for verdadeira. Mas loops com comandos especiais como break (sair do loop) ou continue (pular para próxima iteração) requerem um mecanismo para “saltar” fora da execução normal.
A abordagem comum é usar exceções especiais ou valores de retorno que sinalizam esses casos especiais:
class ControleDeFluoException implements Exception {
final String tipo; // 'break' ou 'continue'
ControleDeFluoException(this.tipo);
}
void executarLoop(NoLoop no, Ambiente ambiente) {
while (true) {
// Avalia condição
Valor condicao = avaliador.avaliar(no.condicao);
if (condicao is! ValorBooleano || !condicao.valor) {
break; // Condição falsa, sai do loop
}
try {
// Executa corpo do loop
executarBloco(no.corpo, ambiente);
} on ControleDeFluoException catch (e) {
if (e.tipo == 'break') {
break; // Sai do loop
} else if (e.tipo == 'continue') {
continue; // Pula para próxima iteração
}
}
}
}Retornos de função são particularmente interessantes e merecem atenção especial. Quando uma função executa um comando retorne, ela precisa imediatamente parar de executar comandos subsequentes e propagar o valor de retorno para o chamador. Em implementações recursivas, isso tipicamente usa uma exceção especial de retorno que é capturada no frame de chamada de função.
⚠️ Implementação de Retorno com Exceções
Considere uma função com múltiplos pontos de retorno:
Funcao encontrar_maximo(Lista numeros)
Se numeros.vazio() entao
retorne -1; // Retorno precoce #1
Fim
Guarde Inteiro maximo como numeros[0];
ParaCada Inteiro num em numeros
Se num > maximo entao
Guarde maximo como num;
Fim
Fim
retorne maximo; // Retorno normal #2
Fim
Quando o primeiro retorne -1 é executado, não podemos simplesmente retornar da função executar_comando() porque estamos várias camadas profundas em chamadas recursivas - estamos dentro de um condicional, que está dentro do corpo da função. Precisamos “desenrolar” toda a pilha de chamadas até o frame onde a função foi originalmente chamada.
A solução elegante é lançar uma exceção especial que “borbulha” para cima:
class ValorDeRetorno implements Exception {
final Valor valor;
ValorDeRetorno(this.valor);
}
void executarRetorno(NoRetorno no, Ambiente ambiente) {
Valor valor = avaliador.avaliar(no.expressao);
throw ValorDeRetorno(valor); // Lança exceção especial
}
Valor chamarFuncao(Funcao funcao, List<Valor> argumentos) {
// Cria novo ambiente para a função
Ambiente ambienteLocal = Ambiente(ambienteGlobal);
// Liga parâmetros a argumentos
for (int i = 0; i < funcao.parametros.length; i++) {
ambienteLocal.definir(funcao.parametros[i], argumentos[i]);
}
try {
// Executa corpo da função
executarBloco(funcao.corpo, ambienteLocal);
// Se chegou aqui, função não retornou explicitamente
throw Exception('Função deve retornar valor');
} on ValorDeRetorno catch (retorno) {
// Captura exceção de retorno e extrai valor
return retorno.valor;
}
}Esta abordagem baseada em exceções para controle de fluxo pode parecer não-idiomática inicialmente, mas é padrão em implementações de interpretadores. Ela separa claramente o fluxo normal de execução dos saltos não-locais, tornando o código mais fácil de entender e manter.
Representação de Valores em Runtime
Interpretadores devem tomar uma decisão arquitetural importante: como representar valores de diferentes tipos durante a execução do programa? Esta escolha afeta tanto a performance quanto a facilidade de implementação. Existem três abordagens principais, cada uma com trade-offs distintos:
Tagged Unions (Uniões Etiquetadas) armazenam cada valor junto com uma tag que indica seu tipo. Cada valor em runtime é uma estrutura contendo um campo de tipo (enum) e uma união de campos para diferentes representações possíveis (int, float, bool, string, etc.). Esta abordagem é especialmente comum em linguagens com tipagem dinâmica onde o tipo de um valor pode mudar durante a execução.
O custo desta flexibilidade é overhead de memória (a tag adiciona bytes extras a cada valor) e necessidade de verificação de tipo em cada operação (precisa checar a tag antes de acessar o campo apropriado da união).
Boxing (Encapsulamento) representa todos os valores - mesmo tipos primitivos como inteiros e floats - como objetos alocados no heap. Cada valor é um ponteiro para uma estrutura heap que contém o tipo e o valor real. Esta abordagem simplifica enormemente o gerenciamento de memória e permite tratamento uniforme de todos os tipos (tudo é um ponteiro).
Mas tem overhead significativo: cada valor primitivo requer alocação heap separada, e cada acesso envolve indireção através de ponteiro. Para programas que fazem muita aritmética com inteiros, este overhead pode ser proibitivo.
Representações Especializadas usam os tipos nativos da linguagem de implementação quando possível. Inteiros da linguagem fonte são representados como ints nativos da linguagem de implementação, floats como doubles nativos, strings como strings nativas. Esta abordagem oferece performance máxima - operações aritméticas usam instruções de máquina diretas sem overhead.
A complexidade surge em operações que precisam tratar valores genericamente. Por exemplo, uma função imprimir(valor) que aceita qualquer tipo precisa de algum mecanismo para determinar o tipo em runtime. Isso geralmente leva a uma abordagem híbrida onde valores carregam informação de tipo leve.
Para linguagens estaticamente tipadas como Didágica, onde tipos são conhecidos em tempo de compilação e verificados pelo analisador semântico, representações especializadas são ideais. O sistema de tipos já garante que operações são aplicadas apenas a valores de tipos apropriados, eliminando a necessidade de verificações dinâmicas custosas na maioria dos casos.
🎯 Exemplo Prático: Escolhendo Representação
Para Didágica com verificação estática de tipos, uma representação eficiente em Dart seria:
// Classe base abstrata para valores
abstract class Valor {}
// Cada tipo tem representação especializada
class ValorInteiro extends Valor {
final int valor; // int nativo do Dart
ValorInteiro(this.valor);
}
class ValorReal extends Valor {
final double valor; // double nativo do Dart
ValorReal(this.valor);
}
class ValorBooleano extends Valor {
final bool valor; // bool nativo do Dart
ValorBooleano(this.valor);
}
class ValorTexto extends Valor {
final String valor; // String nativa do Dart
ValorTexto(this.valor);
}Esta abordagem combina o melhor de ambos mundos:
- Performance: valores internos usam tipos nativos eficientes
- Segurança: hierarquia de classes fornece verificação de tipos
- Simplicidade: código é claro e direto
🎓 Conclusão: O Poder da Interpretação Direta
Você agora compreende profundamente como interpretadores transformam ASTs em comportamento executável. Viu código concreto que implementa avaliação de expressões, gerenciamento de ambientes, controle de fluxo, e representação de valores.
A beleza da interpretação está em sua transparência conceitual. Não há camadas de abstração obscuras ou transformações complexas - o interpretador é essencialmente uma função avaliadora que navega pela AST produzindo efeitos. Esta simplicidade torna interpretadores excelentes ferramentas pedagógicas e ótimas bases para prototipação rápida de linguagens.
Mas você também percebeu as limitações. A cada execução, o interpretador refaz todo o trabalho de percorrer a AST, avaliar expressões, consultar ambientes. Para programas executados milhares ou milhões de vezes, este overhead torna-se proibitivo.
É aqui que compilação entra. Na próxima seção, você explorará como transformar ASTs em representações executáveis otimizadas que podem rodar com velocidade máxima. Prepare-se para descobrir o LLVM IR! 🚀