Fluxo de Controle e Statements num Interpretador
Objetivos de Aprendizagem
- Implementar
if,whilee sequências de statements como tipos comuns de nó de AST com os seus próprios ramoseval/exec, em nada diferentes em espécie de qualquer outro nó. - Distinguir uma EXPRESSÃO (avalia para um valor) de um STATEMENT (executado pelo seu efeito, por exemplo mutar um ambiente), e explicar por que essa distinção importa para o design de um interpretador.
- Rastrear a execução de um laço
whilecomo reavaliação repetida da sua condição e corpo, dirigida inteiramente por chamadas recursivas comuns deeval/execsem maquinaria de laço especial no próprio interpretador. - Explicar por que o fluxo de controle não precisa de nenhum mecanismo de interpretador fundamentalmente novo além do que avaliar um
ifjá exigiu. - Conectar o tratamento de fluxo de controle deste conceito de volta à tradução de nível de assembly de
if/while/forjá coberta emc-and-assembly, contrastando os dois níveis honestamente.
Contexto e Motivação
Pode parecer que o fluxo de controle, if, while, for, precisa de algum tipo genuinamente novo de maquinaria de interpretador: um "motor de laço" especial, talvez, distinto da avaliação comum de expressões. O ponto central deste conceito é que não precisa. Um if já foi implementado, por completo, lá quando a função eval central do interpretador foi construída pela primeira vez, o caso IfExpr simplesmente avalia a sua condição e recursa para qualquer ramo que o resultado selecione. Um laço while acaba por não precisar de nada mais exótico: um nó de AST cujo caso exec (ou eval) reavalia a sua condição e, se verdadeira, executa o seu corpo e então RECURSA SOBRE SI MESMO, recursão comum de função, já coberta extensivamente, fazendo todo o trabalho de "laçar" sem nenhum construto adicional de nível de interpretador exigido.
Isto vale ser enunciado explicitamente porque c-and-assembly já cobriu como if/while/for traduzem para instruções de DESVIO CONDICIONAL no nível de código de máquina, saltos reais, flags de comparação reais, um mecanismo genuinamente diferente (controle transferindo para um endereço de instrução diferente) de qualquer coisa no nível de interpretador. Contrastar os dois honestamente é instrutivo: no nível de hardware, o fluxo de controle é um salto; no nível de interpretador tree-walking, exatamente o mesmo fluxo de controle é recursão comum de chamada de função na linguagem HOSPEDEIRA (Python, na implementação corrente desta disciplina) na qual o próprio interpretador é escrito.
Teoria Central
Expressões vs. statements
Uma EXPRESSÃO avalia para um valor, 2 + 3, if x then y else z, uma chamada de função. Um STATEMENT é executado pelo seu EFEITO, uma atribuição de variável que muta um ambiente, um print, um laço, e em muitos designs de linguagem não produz um valor significativo de forma alguma (ou produz um valor "unit" de preenchimento). Algumas linguagens borram esta distinção (em muitas linguagens funcionais, mesmo um if usado como statement ainda tecnicamente "avalia" para algo); o interpretador desta disciplina mantém tanto eval (para expressões) quanto um exec paralelo (para statements) para manter a distinção explícita e pedagogicamente clara, seguindo a mesma separação que o próprio capítulo "Statements and State" de Crafting Interpreters introduz.
while como recursão comum, não um primitivo especial
plaintextdef exec(stmt, env): match stmt: case ExprStmt(expr): eval(expr, env) case WhileStmt(cond, body): if eval(cond, env): exec(body, env) exec(stmt, env) # recursa sobre o MESMO nó while: ISTO É o laço case BlockStmt(statements): for s in statements: exec(s, env)
Repare em exec(stmt, env) no caso WhileStmt, a função chama a si mesma de novo com o EXATO MESMO stmt (o próprio nó de AST do laço while), não um diferente. Toda "iteração" do laço de nível de fonte é mais um nível de chamada recursiva na linguagem HOSPEDEIRA (Python) na qual o interpretador é implementado. Isto é precisamente por que o fluxo de controle não precisou de nenhum mecanismo novo: é recursão de eval/exec, a mesma estrutura de chamada recursiva já usada em todo lugar neste interpretador, simplesmente aplicada a um nó que por acaso checa a sua própria condição de novo antes de decidir se recursa.
Contraste com o nível de código de máquina
O conceito translating-control-flow-if-while-for de c-and-assembly já mostrou o que um laço while se torna no nível de ISA: um endereço de instrução rotulado, uma instrução de comparação configurando flags de código de condição, e uma instrução de desvio condicional que salta DE VOLTA para o rótulo se a condição ainda se mantém, transferência de controle real para um valor diferente de contador de programa, executada por hardware de CPU dedicado. A implementação de while deste conceito alcança exatamente o mesmo comportamento de nível de fonte por meio de um mecanismo inteiramente diferente: recursão comum de chamada de função em qualquer que seja a linguagem em que o próprio interpretador por acaso é escrito. Ambas são implementações legítimas e corretas de "laçar enquanto uma condição se mantém", elas simplesmente operam em níveis diferentes do sistema, uma em silício, uma em chamadas de função da linguagem hospedeira.
Exemplos Resolvidos
Exemplo 1: Rastreando um laço while contando de 2 para baixo
Fonte: while (x > 0) { x = x - 1 }, começando com x ligado a 2:
textexec(WhileStmt(cond, body), env) [x = 2] eval(cond, env) = (2 > 0) = True exec(body, env) → env.define("x", 1) exec(WhileStmt(cond, body), env) [chamada recursiva, x = 1] eval(cond, env) = (1 > 0) = True exec(body, env) → env.define("x", 0) exec(WhileStmt(cond, body), env) [chamada recursiva, x = 0] eval(cond, env) = (0 > 0) = False return # laço termina, sem recursão adicional
Duas iterações completas, cada uma um nível adicional de CHAMADA RECURSIVA no interpretador hospedeiro, o "laço" de nível de fonte e a própria profundidade de pilha de chamadas do interpretador estão diretamente correlacionados aqui, um perfil de uso de recurso genuinamente diferente de um laço while compilado (que reusa o exato mesmo endereço de instrução em toda iteração, em profundidade de pilha constante).
Exemplo 2: Sequenciando statements num bloco
textBlockStmt([ ExprStmt(AssignExpr("x", NumExpr(1))), ExprStmt(AssignExpr("y", BinaryExpr("+", VarExpr("x"), NumExpr(1)))), ])
exec neste BlockStmt simplesmente itera a sua lista, chamando exec em cada statement em ordem, passando o MESMO ambiente por cada um, então o eval de x do segundo statement vê corretamente o valor 1 que o primeiro statement acabou de ligar. Sequenciar, como laçar, não precisa de nenhum mecanismo especial além de "rode estes, um após o outro, compartilhando o mesmo ambiente".
Exemplo 3: O custo honesto, tornado concreto com um número
textUm laço while que roda 100.000 iterações, sob a implementação tree-walking desta disciplina, soma até 100.000 níveis de chamadas exec() RECURSIVAS na linguagem hospedeira antes de a primeira retornar, uma diferença real e mensurável de um laço compilado (a versão de c-and-assembly), que reusa UM endereço de instrução 100.000 vezes em profundidade de pilha CONSTANTE, nunca fazendo a pilha de chamadas crescer de forma alguma. Consequência prática: um laço suficientemente longo neste estilo de interpretador tree-walking ingênuo pode esgotar o próprio limite de pilha de chamadas da linguagem HOSPEDEIRA (o limite de recursão padrão do Python, por exemplo), uma restrição de implementação real, não uma puramente teórica.
Interpretadores tree-walking de produção tipicamente reestruturam a execução de laço como um LAÇO real da linguagem hospedeira (não recursão) especificamente para evitar isto, uma otimização que a apresentação simplificada desta disciplina deliberadamente põe de lado em favor de mostrar a conexão mais limpa possível entre "laço" e "recursão".
Equívocos Comuns e Armadilhas
- "Um interpretador precisa de um mecanismo fundamentalmente diferente para tratar laços versus condicionais." Não precisa, ambos são tipos comuns de nó de AST com o seu próprio ramo
eval/exec, e o "laçar" de umwhilenada mais é do que esse ramo recursando sobre o seu próprio nó, exatamente a mesma técnica de chamada recursiva usada para todo outro construto neste interpretador. - "Já que
c-and-assemblyjá cobriu comowhilecompila para desvios, este conceito é redundante." Eles descrevem mecanismos genuinamente diferentes em níveis genuinamente diferentes, um endereço de salto rotulado e flags de código de condição no nível de hardware, versus recursão de chamada de função da linguagem hospedeira no nível de interpretador tree-walking, ambos corretos, nenhum redundante com o outro. - "Expressões e statements são realmente a mesma coisa, só chamados por nomes diferentes." Muitas linguagens reais de fato borram a linha (algumas deixam um
if-como-statement ainda produzir um valor), mas a distinção subjacente, avalia para um valor vs. executado pelo efeito, é real e vale manter explícita, que é exatamente por que este interpretador mantém funçõesevaleexecseparadas em vez de fundi-las. - "Laçar baseado em chamada recursiva é 'errado' ou um bug, já que usa profundidade de pilha proporcional à contagem de iterações." É um custo real e reconhecido da implementação tree-walking mais simples possível (mostrado honestamente no Exemplo 3), não um defeito na ideia subjacente de usar recursão para fluxo de controle, interpretadores de produção o enfrentam com uma otimização explícita (reestruturar a execução de laço como um laço da linguagem hospedeira), que o tratamento introdutório desta disciplina põe de lado por clareza.
Resumo
if, while e o sequenciamento de statements não exigem nenhum mecanismo novo de interpretador além do que avaliar um if já introduziu: cada um é um tipo comum de nó de AST com o seu próprio ramo eval/exec, e o comportamento de laçar de um while é exatamente o seu ramo exec recursando sobre o seu próprio nó de AST enquanto a sua condição se mantém, recursão comum de função da linguagem hospedeira, não um construto de laço especial. Isto espelha, no nível de interpretador, os mesmos construtos de fluxo de controle que c-and-assembly já mostrou compilando para instruções de desvio condicional rotuladas no nível de código de máquina, duas implementações inteiramente diferentes, ambas legítimas, do comportamento idêntico de nível de fonte, em duas camadas diferentes do sistema. O custo honesto da abordagem baseada em recursão, profundidade de pilha proporcional à contagem de iterações, é um trade-off real e reconhecido da apresentação tree-walking mais simples possível desta disciplina.