Moacyr Francischetti Corrêa

1 Módulo 14: Geração de Código

Bem-vindo ao módulo em que aparece um arquivo. Até agora, tudo o que o seu compilador produziu morreu junto com o processo. Aqui ele passa a escrever algo que sobrevive, que você copia para outra máquina e executa semana que vem. É também o módulo em que a natureza do trabalho muda: pela primeira vez não existe a resposta certa, existem muitas respostas corretas, e a tarefa é escolher uma boa.

1.1 O problema: um verificador caríssimo

Imagina a cena. Você roda o seu compilador sobre um programa: ele analisa o texto, monta a árvore, confere os tipos, gera a representação intermediária com temporários e desvios — e termina. O processo devolve a memória ao sistema operacional e não deixa rastro. O que você tem em mãos é um programa que sabe dizer “está bem formado” e nada mais. Um verificador caríssimo.

flowchart LR
    A["texto-fonte"] --> B["análise<br/>tokens, árvore, tipos"]
    B --> C["representação intermediária<br/>vive na memória do processo"]
    C --> D["gerador de código"]
    D --> E["programa objeto<br/>arquivo que sobrevive ao processo"]
    E --> F["executor"]
    F --> G["efeito observável<br/>sobre a entrada real"]
    C -. "até aqui, tudo morria<br/>com o fim do processo" .-> C
Figura 1: O ponto da cadeia em que o compilador deixa de produzir estrutura interna e passa a produzir arquivo.

Aviso de mudança de tom. Na metade de análise, as perguntas tinham resposta única e havia critério objetivo separando certo de errado. Aqui não há: dada uma representação intermediária, infinitas sequências de instruções produzem o efeito correto. Por isso o que vem a seguir trata de compromissos, e não de teoremas.

Este capítulo é o que mais depende do anterior em todo o livro, e quero ser explícito sobre o que ele pressupõe, porque a lacuna, se houver, custa caro aqui.

Pressuponho que você tenha em mãos uma representação intermediária linear, no estilo do código de três endereços, com temporários, desvios condicionais e incondicionais, e com as listas de desvios pendentes já preenchidas para os operadores lógicos — o mecanismo que construímos ao tratar de representações intermediárias. Pressuponho também uma especificação escrita do formato do programa objeto e do modelo de execução: quais seções o arquivo tem, o que cada campo significa, e o que o executor faz a cada passo. Se essa especificação não existir, ou existir apenas na sua cabeça, a leitura deste capítulo funciona, mas a implementação não: sem ela, o executor acaba escrito contra o gerador, e o capítulo inteiro argumenta contra isso.

Do primeiro bloco do livro, pressuponho o caminho completo da expressão regular ao autômato finito determinístico mínimo — notação, árvore, construção de Thompson, determinização por subconjuntos e minimização por refinamento de partições —, porque a Seção 8 volta a esse material e o trata como dado a ser gravado em um arquivo, e não mais como algoritmo a ser executado. Pressuponho ainda o vocabulário de grafos: vértice, aresta, grau, coloração própria e percurso, que é o que a Seção 7 usa sem reapresentar.

O que não pressuponho é qualquer familiaridade com arquiteturas de processadores reais, com conjuntos de instruções concretos ou com formatos de arquivo objeto de sistemas operacionais em uso. Tudo o que este capítulo precisa dessas coisas é construído aqui, em uma máquina abstrata pequena o bastante para caber inteira em uma página.

1.2 Escolher, não decidir

Um gerador de código é uma função G que leva cada representação intermediária bem formada r a uma sequência de instruções da máquina, de modo que executar G(r) sobre uma entrada produza o mesmo resultado que a semântica atribui a r sobre ela. É a igualdade de significados do primeiro módulo, aplicada ao último trecho da cadeia.

O que é novo é que essa condição não determina G: dá sempre para acrescentar instruções sem efeito observável, reordenar operações independentes, chegar ao mesmo lugar por outro caminho. E aí, uma boa sob qual critério? Pode ser o tempo de execução, o tamanho do código, o consumo de energia ou a previsibilidade do tempo de resposta — e neste último caso uma transformação que reduz o tempo médio e piora o pior caso é uma piora.

Pare e pense. Alguém diz que o compilador A gera código melhor que o B. Que pergunta você faz antes de acreditar?

O problema se decompõe em três subproblemas. A seleção de instruções decide quais instruções realizam cada operação da representação. Parece consulta a tabela, e é isso quando o conjunto é pequeno e regular; deixa de ser trivial quando a máquina oferece mais de uma forma de fazer a mesma coisa, com custos diferentes, e algumas delas cobrem várias operações de uma vez. Quando a representação tem forma de árvore, esse ladrilhamento de custo mínimo tem solução ótima por programação dinâmica em tempo linear, resultado de Alfred Aho e Stephen Johnson publicado em 1976 — um dos pouquíssimos lugares da área em que há algoritmo ótimo e não heurística, porque a árvore impede que a escolha numa subárvore afete a outra.

A alocação de registradores decorre de um fato bruto: a máquina tem poucos registradores e o programa produz um número ilimitado de valores intermediários. Quando eles não bastam, algum valor vai para a memória — é o derramamento, uma derrota localizada, porque o código continua correto e fica mais lento.

A ordenação da avaliação é a mais sutil: ordens diferentes de avaliar subexpressões independentes produzem números diferentes de valores vivos ao mesmo tempo, porque, ao somar dois produtos, o primeiro calculado ocupa espaço durante todo o cálculo do segundo. Ravi Sethi e Jeffrey Ullman resolveram isso exatamente para árvores, em 1970: rotule cada folha com 1 e cada nó com o máximo dos rótulos dos filhos quando diferem, ou com esse valor mais um quando são iguais; o rótulo da raiz é o número mínimo de registradores, atingido avaliando sempre primeiro a subárvore de maior rótulo. As hipóteses delimitam tudo — precisa ser árvore de verdade, e os operadores precisam ser livres de efeito colateral.

flowchart LR
    S["seleção de instruções<br/>quais instruções cobrem<br/>cada operação"]
    A["alocação de registradores<br/>quais valores moram<br/>em registrador"]
    O["ordenação da avaliação<br/>em que ordem avaliar<br/>subexpressões independentes"]
    S -- "instruções escolhidas mudam<br/>os tempos de vida" --> A
    A -- "registradores disponíveis mudam<br/>o que compensa selecionar" --> S
    O -- "a ordem decide quem<br/>está vivo ao mesmo tempo" --> A
    A -- "a pressão sobre registradores<br/>sugere outra ordem" --> O
    S -- "cada instrução consome<br/>uma quantidade diferente" --> O
Figura 2: O ciclo de dependências entre os três subproblemas, que é o que impede resolvê-los em separado de forma ótima.

Agora o incômodo: os três se mordem a cauda. A melhor seleção depende de quantos registradores há, a alocação depende da ordem de avaliação, e a melhor ordem depende de quais instruções foram selecionadas. Formulado como problema único, o conjunto é intratável — ele contém a coloração de grafos como caso particular. A prática decompõe e aceita o subótimo, decisão consciente que troca otimalidade por tratabilidade e por modularidade, porque três componentes separados se testam e se substituem, e um bloco monolítico não se depura.

A ordem de prioridades é contraintuitiva. Primeiro correção, inegociável; segundo previsibilidade, porque um gerador fácil de descrever produz código conferível por inspeção; só em terceiro a qualidade do código gerado. Inverter essa ordem é a forma mais comum de terminar o semestre com um gerador sofisticado que não funciona.

1.3 Pilha ou registradores: onde mora a dificuldade

Escolher o modelo de máquina não elimina dificuldade — redistribui.

O estado é o contador de instruções, uma pilha de avaliação e a memória. As instruções aritméticas e lógicas não nomeiam operandos: retiram do topo o que consomem e depositam no topo o que produzem. O gerador não decide onde pôr nada, porque a posição faz o trabalho do nome.

O estado inclui um conjunto finito e nomeado de posições rápidas, e cada instrução diz de onde lê e onde escreve. Executa menos instruções, porque não gasta nenhuma movendo valores para dentro e para fora de uma pilha, e em troca transfere ao gerador a decisão de qual valor mora em qual registrador.

flowchart TB
    E["mesma operação:<br/>somar dois valores"]
    E --> P["máquina de pilha"]
    E --> R["máquina de registradores"]
    P --> P1["carrega primeiro valor"]
    P1 --> P2["carrega segundo valor"]
    P2 --> P3["soma<br/>sem nomear nada"]
    P3 --> PC["gerador simples<br/>executor simples<br/>mais instruções executadas"]
    R --> R1["soma r1 e r2<br/>e escreve em r3"]
    R1 --> RC["menos instruções<br/>gerador carrega a<br/>alocação de registradores"]
Figura 3: A mesma soma nos dois modelos, e para que lado cada um empurra o custo.
Onde cada modelo põe a dificuldade
Aspecto Máquina de pilha Máquina de registradores
Operandos das operações implícitos, no topo nomeados na instrução
Alocação de registradores não existe é o subproblema dominante
Ordenação da avaliação fixada pela pós-ordem há liberdade a explorar
Tamanho do código menor maior
Instruções executadas mais menos
Dificuldade do gerador baixa alta
Verificação estática direta, pela altura da pilha mais trabalhosa

Leia a tabela como redistribuição, não como classificação de qualidade: a dificuldade total é aproximadamente constante, o que muda é onde ela mora. E note o que costuma passar batido: código de três endereços é código para uma máquina de registradores ilimitados, com cada temporário no papel de registrador virtual. A alocação é o mapeamento desse conjunto no conjunto finito da máquina real — e não existe quando o destino é uma pilha, porque não há nomes nos quais mapear.

Prefira a pilha quando o destino for uma máquina virtual que você mesmo escreverá, quando a portabilidade importar mais que o desempenho bruto, quando o código precisar ser verificado antes de executar, ou quando as expressões forem rasas — esta última condição é a mais esquecida e é decisiva, porque se as expressões nunca passam de dois ou três níveis, a pilha nunca fica funda e a simplicidade do gerador é ganho puro. Prefira registradores quando o destino for processador real e o desempenho for dominante. E note o que a escolha não decide: a representação intermediária. Usar três endereços e gerar para pilha é coerente, e é o que faremos.

1.4 Projetar o conjunto de instruções

Aqui você deixa de ser usuário de um conjunto de instruções e passa a ser projetista de um — e entender por que uma instrução existe é diferente de saber usá-la.

flowchart TB
    I["instrução"] --> C["código de operação"]
    I --> A["argumento<br/>sempre um índice inteiro"]
    I --> D["declaração do efeito"]
    D --> D1["quantos valores consome"]
    D --> D2["quantos valores deposita"]
    D --> D3["que faz com o<br/>contador de instruções"]
    D1 --> H["efeito de pilha<br/>depositados menos consumidos"]
    D2 --> H
    H --> V["altura conhecida<br/>sem executar nada"]
    V --> V1["profundidade máxima reservada<br/>antes de começar"]
    V --> V2["programa cuja pilha<br/>não fecha é rejeitado"]
Figura 4: O que uma instrução precisa declarar, e o que a declaração do efeito compra de graça.

Uma instrução é caracterizada por um código de operação, um argumento opcional e — a parte que quase todo mundo omite — a declaração do seu efeito: quantos valores consome, quantos deposita, o que faz com o contador e com a memória. É essa terceira componente que transforma o conjunto em especificação, em vez de lista de nomes. Instrução com efeito não declarado é instrução sobre a qual duas pessoas discordam de boa-fé, e discordância entre quem gera e quem executa é o defeito mais caro desta altura do projeto: cada lado testa contra a própria interpretação e os dois passam.

Declare por escrito qual operando sai primeiro da pilha. O segundo foi empilhado por último e sai primeiro. Numa soma ou numa multiplicação, trocar a ordem não altera nada e o erro passa despercebido. Numa subtração, numa divisão ou numa comparação de ordem, o resultado sai errado — e só nos casos assimétricos, o que significa que metade dos testes ingênuos continua passando alegremente.

Do efeito declarado sai uma verificação barata. O efeito de pilha de uma instrução é a diferença entre o que ela deposita e o que consome, e sobre código linear a altura em cada ponto é a soma desses efeitos, conhecida estaticamente. Com desvios, a altura continua bem definida desde que todos os caminhos que chegam a um ponto o deixem na mesma altura — e o que importa aí não é a demonstração, que é indução imediata, e sim o requisito de projeto: faça todo desvio saltar um trecho cujo efeito líquido seja igual ao do caminho alternativo, e ganha de graça a profundidade máxima a reservar e a rejeição de qualquer objeto cuja pilha não fecha.

Três decisões de formato fecham a seção. Tamanho fixo das instruções desperdiça alguns bytes e compra um executor mais simples, a impossibilidade estrutural de um desvio cair no meio de uma instrução, e a indexação do código como vetor. Granularidade: cubra cada operação da representação sem duplicar nenhuma, e resista à tentação de instruções especializadas sem evidência de que se pagam; instruções de forma idêntica merecem virar família — das quinze da nossa máquina, seis são de comparação, igualdade, desigualdade e as quatro de ordem, e viram um caso único no executor parametrizado pela variante. E a área de constantes: um vetor de literais gravado uma vez e referenciado por índice, que além de acomodar textos longos uniformiza a decodificação.

O teste do implementador independente

A sua especificação está completa quando outra pessoa, lendo apenas ela, consegue escrever um executor compatível. Comportamento que só se descobre lendo o seu código não está especificado — está implementado, que é coisa diferente. E é isso que permite escrever o executor contra a especificação, como segunda implementação independente: escrito contra o gerador, ele concorda com os defeitos do gerador e todos os testes passam.

1.5 Emitir por pós-ordem, e a invariante que sustenta tudo

Com a máquina projetada, o gerador fica curto. O percurso em pós-ordem visita, para cada nó interno, todos os filhos da esquerda para a direita e só então o próprio nó.

flowchart TB
    S["subtração<br/>visitada em 7º"] --> M["produto<br/>visitado em 3º"]
    S --> D["soma<br/>visitada em 6º"]
    M --> F1["valor<br/>1º"]
    M --> F2["valor<br/>2º"]
    D --> F3["valor<br/>4º"]
    D --> F4["valor<br/>5º"]
Figura 5: A ordem de visita de uma expressão que subtrai, de um produto, uma soma.

Se o gerador emite, para cada nó visitado nessa ordem, a carga do valor (folhas) ou a instrução da operação (nós internos), a execução deixa na pilha exatamente um valor, igual ao da expressão. A demonstração é indução sobre a altura: cada subsequência deixa um valor e não perturba o que está abaixo, de modo que os operandos estão todos lá quando a instrução da raiz executa. Acompanhe o diagrama: carregar, carregar, multiplicar, carregar, carregar, somar, subtrair — sete instruções, e a altura percorre um, dois, um, dois, três, dois, um. O pico de três acontece quando o produto espera enquanto a soma é calculada, que é o fenômeno que a numeração de Sethi e Ullman quantifica.

A pós-ordem não é escolha estética: a instrução da operação exige os operandos já empilhados, e emiti-la antes deles produziria uma instrução consumindo de uma pilha vazia. É a única ordem possível, e é consequência do modelo.

Agora a propriedade silenciosa da qual tudo depende. Uma sequência satisfaz a invariante de leitura única quando cada valor depositado é consumido por exatamente uma instrução posterior. A razão é elementar: numa máquina de pilha, ler é desempilhar, e um valor lido some. Se duas instruções pretenderem ler o mesmo valor, a segunda encontra a pilha já consumida e lê o que estiver abaixo — outro valor, do tamanho certo e possivelmente do tipo certo, e portanto um erro que não se anuncia.

Pare e pense. Código de três endereços permite definir um temporário uma vez e lê-lo duas — é assim que se reaproveita um resultado calculado. O que acontece com a tradução direta para pilha nesse caso, e por que os seus testes de soma e de igualdade continuariam passando?

Ou você constrói a representação de modo que satisfaça a invariante, recalculando o valor a cada uso, ou acrescenta à máquina uma instrução de duplicação do topo. Escolhida qualquer das duas, faça a terceira coisa: verifique a invariante em código, contando as leituras de cada temporário e recusando a geração quando alguma passar de um. Garantias “por construção” são justamente as que se perdem em manutenção. E cuidado ao implementar a contagem: varrer todos os campos de operando conta como temporário coisas que não são, como literais e índices.

1.6 Curto-circuito: quando gerar deixa de ser traduzir

Chegamos ao ponto tecnicamente mais difícil do módulo. Até aqui a geração foi tradução direta; agora o gerador passa a construir estrutura de controle que não corresponde a nada visível no fonte.

Um operador lógico é avaliado com curto-circuito quando o operando direito só é avaliado nos casos em que o esquerdo não decide sozinho. Primeira coisa a desentortar: curto-circuito não é otimização. Ele altera o comportamento observável de programas cujo operando direito tem efeito colateral ou pode falhar — um programa que testa se um índice é válido e, na conjunção, acessa a posição funciona sob curto-circuito e falha sob avaliação ansiosa. Os dois esquemas não são duas implementações da mesma linguagem: são duas linguagens diferentes, e a escolha pertence à especificação, não ao gerador.

A dificuldade técnica vem do modelo de máquina. Suponha uma instrução de conjunção que consome dois valores lógicos e deposita a conjunção. Para executá-la, os dois precisam estar na pilha; para estarem na pilha, as duas subexpressões precisam ter sido avaliadas. Logo, ela implementa necessariamente avaliação ansiosa. Curto-circuito não é operação sobre valores: é fluxo de controle, e se exprime com desvios condicionais.

flowchart TB
    A["código do operando esquerdo"] --> B{"resultado"}
    B -- "falso" --> F["destino da lista de falso<br/>preenchido depois"]
    B -- "verdadeiro" --> C["código do operando direito<br/>começa aqui"]
    C --> E{"resultado"}
    E -- "falso" --> F
    E -- "verdadeiro" --> G["corpo da regra"]
    F -. "as duas listas de falso<br/>foram unidas" .-> F
Figura 6: A tradução de uma conjunção por fluxo de controle, com as duas listas de falso unidas.

O esquema consagrado inverte a perspectiva: em vez de traduzir a expressão lógica para código que calcula verdadeiro ou falso, traduz-se para código que desvia para um lugar quando ela é verdadeira e para outro quando é falsa. Junto com o código, o gerador devolve dois conjuntos de desvios com destino em branco: a lista de verdadeiro e a lista de falso. Numa comparação simples, emitem-se os operandos, a comparação e um desvio condicional em branco, que entra na lista de falso. Numa conjunção, traduz-se o esquerdo, preenche-se a sua lista de verdadeiro com a posição em que o direito começa, traduz-se o direito, e devolve-se como lista de falso a união das duas — lido em português, é a semântica: se o esquerdo for verdadeiro, siga para o direito; se qualquer um for falso, o destino é o mesmo. Na disjunção o esquema é simétrico, com as listas trocando de papel, e o aninhamento não exige nada de novo, porque as listas são a interface entre os níveis.

Falta preencher as listas. Quando o gerador emite um desvio para a frente, o endereço do destino não é difícil de calcular: é inexistente. Ou se gera em duas passagens, ou se emite o desvio em branco, guarda-se a posição e volta-se a preenchê-la depois — o preenchimento retroativo. Uma referência pendente jamais preenchida é defeito grave e silencioso: o programa desvia para um lugar arbitrário na primeira execução que passar por ali.

1.7 Referências pendentes e o mapa de endereços

O problema do desvio para a frente é mais geral que o curto-circuito. Uma referência pendente é qualquer campo de instrução já emitida cujo valor correto ainda não se conhece — destinos de desvio, endereços de dados não alocados, nomes definidos em outra unidade de compilação.

flowchart LR
    subgraph P1["primeira passagem: emitir"]
        A["percorre a representação"] --> B["registra em que endereço<br/>do objeto cada instrução começou"]
        B --> C["emite o desvio com<br/>destino em branco"]
    end
    subgraph P2["segunda passagem: preencher"]
        D["percorre os desvios emitidos"] --> E["consulta o mapa"]
        E --> F["substitui o índice da representação<br/>pelo endereço do objeto"]
    end
    C --> D
    B -. "o mapa tem um elemento a mais:<br/>logo depois da última é<br/>destino legítimo" .-> E
Figura 7: As duas passagens e o mapa que traduz a numeração da representação para a do objeto.

O caso mais frequente é este: os desvios da representação apontam para índices da representação, e o objeto precisa de índices do objeto. A solução é um mapa preenchido durante a emissão e consultado numa segunda passagem, que substitui cada destino intermediário pelo endereço correspondente do objeto. Não tente corrigir na mesma passagem que emite: a única forma seria voltar atrás depois, que já é a segunda passagem, escrita pior.

Dois detalhes do mapa produzem defeitos reais quando esquecidos. Ele precisa ter um elemento a mais que o número de instruções da representação, porque “logo depois da última” é destino legítimo — é para lá que aponta o desvio de uma condição falsa cujo corpo termina no fim do trecho. E nem toda instrução da representação produz uma do objeto: uma operação de rótulo não emite nada, e ali o contador da representação avança enquanto o do objeto não.

Coincidência não é invariante. É comum a correspondência sair um para um e o mapa acabar sendo a identidade, e é natural concluir dali que ele é desnecessário — errado de um jeito que não se manifesta. Uma operação de rótulo já quebra a identidade; uma instrução futura traduzida por duas de máquina também. Quando quebrar, não haverá mensagem de erro: haverá um desvio para a instrução errada.

1.8 Alocação de registradores como coloração

Este subproblema some quando o destino é uma máquina de pilha, e ainda assim vale estudá-lo: a redução a coloração de grafo é um dos exemplos mais limpos de modelagem em construção de compiladores, e qualquer geração para hardware real passa por aqui.

A faixa de vida de um temporário vai da instrução que o define até a última que o lê, e o intervalo é semiaberto, com o extremo direito excluído. Não é convenção arbitrária: um valor morre na instrução que o lê pela última vez, e depois que ela executou o registrador está livre, inclusive para receber o valor que essa mesma instrução acabou de produzir. Dois temporários interferem quando suas faixas se sobrepõem, e o grafo de interferência tem os temporários como vértices e a interferência como arestas.

flowchart LR
    T0["t0<br/>vive de 0 a 1"] --- T1["t1<br/>vive de 1 a 3"]
    T1 --- T2["t2<br/>vive de 2 a 3"]
    T3["t3<br/>vive de 3 a 4"]
    T0 -.- L1["cor 0"]
    T2 -.- L2["cor 1"]
    T3 -.- L1
Figura 8: Faixas de vida, interferências e cores: temporários que não se sobrepõem reaproveitam a mesma cor.

A redução é exata: uma atribuição de registradores é válida se e somente se é uma coloração própria do grafo de interferência, e o número mínimo de registradores é o número cromático. A demonstração é a leitura das definições nos dois sentidos — “temporários que interferem recebem registradores diferentes” e “vértices adjacentes recebem cores diferentes” são a mesma condição, com vocabulário trocado. É por isso que gosto deste exemplo: modelar bem é achar um problema matemático que é o mesmo problema, não um parecido. O primeiro alocador construído sobre essa correspondência deve-se a Gregory Chaitin e colaboradores, na IBM, no início dos anos 1980.

A má notícia vem junto: decidir se um grafo admite coloração com k cores é NP-completo para todo k \ge 3, e o problema está na lista clássica de Richard Karp, de 1972. Como a redução é exata, a alocação ótima herda a dificuldade — compiladores reais usam heurística porque a alternativa é intratável. A heurística fundadora é a simplificação: enquanto houver vértice de grau menor que k, remova-o e empilhe-o, porque quem tem menos de k vizinhos sempre acha cor livre na reinserção; esvaziado o grafo, desempilhe atribuindo a menor cor não usada pelos vizinhos. Se os restantes tiverem grau alto demais, escolhe-se um para derramar.

O erro clássico, e por que ele é perigoso. Tratar as faixas de vida como intervalos fechados faz o resultado de uma instrução interferir com os operandos que ela acabou de matar, e o alocador conclui que precisa de mais registradores do que precisa. O código sai correto e desnecessariamente conservador: nenhum teste acusa, o compilador não reclama, e o número devolvido é plausível — três registradores para uma expressão de profundidade dois não tem cara de defeito. Defeitos que produzem números implausíveis se denunciam sozinhos; os que produzem números plausíveis moram no código por anos.

Uma boa notícia para fechar. Dentro de um trecho linear, cada faixa de vida é um intervalo da reta e o grafo de interferência é um grafo de intervalos, classe para a qual a coloração ótima sai por um algoritmo guloso; a dificuldade geral vem da alocação entre blocos, quando o fluxo de controle espalha as faixas. Isso explica a coincidência entre o número de cores e a altura máxima da pilha: com temporários lidos uma única vez e código linear, o número de valores vivos num ponto é a altura da pilha ali. As duas máquinas precisam do mesmo espaço de trabalho; o que difere é quem o endereça.

1.9 O objeto como artefato: quando os autômatos viram produto

Um programa objeto tem seções de código, com instruções, e seções de dados, com valores determinados em tempo de compilação que o programa consultará durante a execução. É na seção de dados que mora a novidade deste módulo.

flowchart TB
    O["programa objeto"] --> D["seção de dados"]
    O --> C["seção de código"]
    D --> D1["área de constantes<br/>literais referenciados por índice"]
    D --> D2["tabelas de transição dos autômatos<br/>determinísticos mínimos"]
    D --> D3["propriedades apuradas<br/>na análise semântica"]
    C --> C1["instruções de tamanho fixo<br/>código e argumento"]
    D2 -. "milhares de células" .-> M["a maior parte do arquivo,<br/>em bytes, é autômato"]
    C1 -. "algumas dezenas" .-> M
Figura 9: O que vai dentro do arquivo gerado, e a proporção que inverte a intuição.

Considere uma linguagem cujo domínio seja o reconhecimento de padrões em texto. Compilar um programa dela envolve converter cada padrão declarado num reconhecedor, e um reconhecedor é um autômato finito determinístico — construído pelo caminho dos primeiros módulos: da notação para a árvore, da árvore para o autômato não determinístico pela construção de Thompson, deste para o determinístico por subconjuntos, e deste para o mínimo por refinamento de partições. O que o gerador faz com esses autômatos? Ele os serializa na seção de dados: a tabela de transição de cada um vira um vetor de números gravado no arquivo, e o executor a consulta uma vez por caractere da entrada.

Lá atrás, os autômatos eram mecanismo interno do analisador léxico, invisível no resultado; aqui aparecem do outro lado, executados pelo programa que o compilador produziu. Não é analogia — é o mesmo objeto matemático, com a mesma tabela de transição, reaparecendo como produto. E há um detalhe quantitativo que inverte a intuição: um autômato com poucas dezenas de estados sobre um alfabeto de bytes ocupa milhares de células, enquanto as instruções que implementam as regras são algumas dezenas. A maior parte do arquivo, em bytes, é autômato.

Duas decisões de formato merecem nota. Dentro do compilador, a função de transição é convenientemente esparsa, porque o autômato é construído e transformado repetidamente; no objeto, a decisão se inverte para matriz densa, mesmo com quase todas as células vazias, porque ali ele não é mais transformado, apenas consultado, no laço mais interno do executor. A representação deve seguir o papel que a estrutura desempenha, não a sua natureza matemática. A segunda decisão é gravar o objeto em forma textual: a binária é menor, mas a textual pode ser lida, e conferir à mão se a tabela tem os estados esperados encontra o que nenhum teste automático encontraria, porque o teste verifica o que alguém pensou em verificar e a leitura vê o que está lá.

1.10 O caso conduzido

Antes de olhar o código, fixe o que procurar nele. Procure onde os três subproblemas aparecem e onde eles somem, e por quê. Procure a tabela de seleção, que deve ser curta, o mapa de endereços e a segunda passagem. Procure a verificação da invariante de leitura única, que existe apesar de a invariante ser garantida por construção. E procure a comparação entre o número de cores e a altura máxima da pilha.

1.10.1 9.1 A máquina de destino da Peneira

A linguagem que este livro constrói compila para uma máquina de pilha, e vale reconstruir o argumento da Seção 2.4 aplicado a ela, porque a decisão não é automática.

As expressões da Peneira são rasas. Uma condição típica é uma comparação, eventualmente duas ligadas por um conectivo lógico; não há chamadas de função, não há aritmética composta, não há aninhamento profundo. Sob a Seção 2.4, essa é exatamente a situação em que a máquina de pilha ganha: a pilha nunca fica funda, o custo de empilhar e desempilhar é pequeno em termos absolutos, e a simplicidade do gerador é ganho puro. Numa linguagem com expressões profundas a conta se inverteria, e registro isso porque a escolha certa aqui é a errada em outro projeto.

O conjunto de instruções tem quinze códigos de operação, dos quais seis formam a família das comparações — igualdade, desigualdade e as quatro de ordem —, tratadas como família pela razão da Seção 3.4: elas têm forma idêntica e um único caso no executor, parametrizado pela variante. As demais empilham uma constante da área de constantes, empilham o texto reconhecido pela ligação corrente, convertem texto em número, combinam valores lógicos de forma ansiosa, desviam condicional ou incondicionalmente, emitem um resultado rotulado e encerram a regra.

Duas observações sobre esse conjunto, ambas de decisões que preferi registrar a silenciar.

A primeira é que as duas instruções de desvio foram acréscimo deliberado ao desenho original da máquina. O desenho inicial listava conjunção e disjunção como instruções, o que, pela Seção 5.2, implica avaliação ansiosa e torna o curto-circuito inexprimível. Mantive as duas no conjunto — elas continuam válidas, e são o que um gerador ansioso emitiria — e acrescentei os desvios, que são o que o nosso gerador de fato usa. Um executor compatível precisa implementar todas.

A segunda é que toda instrução ocupa dois campos, código e argumento, mesmo quando o argumento não é usado. É a decisão de tamanho fixo da Seção 3.3, com as três consequências que discuti ali: avançar é somar uma constante, um desvio nunca cai no meio de uma instrução, e o código é indexável como vetor.

1.10.2 9.2 A seleção de instruções, e os dois subproblemas que somem

Os três subproblemas da Seção 1 estão todos presentes neste compilador, e dois deles desaparecem — não por esperteza, mas como consequência de decisões já tomadas.

A ordenação da avaliação foi resolvida no capítulo anterior: o código de três endereços é linear, e a ordem de avaliação está na sequência das instruções. Não há nada a ordenar aqui. A alocação de registradores não existe, porque a máquina de pilha não tem registradores — o operando fica no topo, e o topo é implícito.

Sobra a seleção, que aqui é quase uma tabela: cada operação da representação intermediária tem uma instrução correspondente.

14_codegen.h
#ifndef PENEIRA_14_CODEGEN_H
#define PENEIRA_14_CODEGEN_H

#include <cstddef>
#include <string>
#include <vector>

#include "10_ast.h"
#include "12_sema.h"
#include "13_objeto.h"
#include "13_ri.h"

namespace peneira {

// GERAÇÃO DE CÓDIGO: da representação intermediária para o programa objeto.
//
// Os três subproblemas clássicos da geração de código são seleção de
// instruções, alocação de registradores e ordenação da avaliação, e eles são
// interdependentes — a melhor seleção depende de quantos registradores
// sobraram, que depende da ordem escolhida, que depende de quais instruções
// foram selecionadas. Resolver os três juntos é intratável; a prática é
// decompor e aceitar o resultado subótimo.
//
// Nesta máquina de destino, dois dos três **desaparecem**, e vale saber por
// quê antes de ler o código:
//
//   - A ORDENAÇÃO já foi decidida no módulo 13. O código de três endereços é
//     linear: a ordem de avaliação está na sequência das instruções.
//   - A ALOCAÇÃO DE REGISTRADORES não existe, porque a máquina é de pilha e
//     não tem registradores. O operando fica no topo, e o topo é implícito.
//
// Sobra a SELEÇÃO, que aqui é quase uma tabela: cada operação da representação
// intermediária tem uma instrução correspondente. Essa simplicidade é o que se
// compra ao escolher máquina de pilha, e o preço está pago no módulo 13 — o
// preço é que o objeto executa mais instruções do que executaria numa máquina
// de registradores.
//
// Para não deixar a alocação de registradores como assunto puramente verbal,
// este módulo CALCULA o que ela custaria: monta o grafo de interferência dos
// temporários da representação intermediária e o colore. O número de cores é o
// número de registradores que uma máquina de registradores precisaria.

// ---------------------------------------------------------------------------
// Geração
// ---------------------------------------------------------------------------

struct RelatorioDeGeracao {
    std::size_t regrasGeradas = 0;
    std::size_t instrucoesEmitidas = 0;
    std::size_t referenciasResolvidas = 0;
    // Profundidade máxima da pilha de avaliação, calculada por simulação
    // estática. O executor pode pré-alocar exatamente isto.
    std::size_t profundidadeMaximaDaPilha = 0;
    // Falha dura: um temporário lido mais de uma vez quebraria a disciplina de
    // pilha (o valor é consumido ao ser lido). Ver `verificarUsoUnico`.
    std::vector<std::string> violacoesDeUsoUnico;
};

// Gera o programa objeto completo a partir do resultado da análise semântica e
// da árvore verificada.
ProgramaObjeto gerarObjeto(const ResultadoSemantico& semantico,
                           const NoAst& raiz, RelatorioDeGeracao& relatorio);

// Traduz o código de uma regra. Exposta separadamente porque é o miolo do
// módulo e a demonstração a exercita isolada.
std::vector<Instrucao> gerarCodigoDaRegra(const CodigoRI& ri,
                                          ProgramaObjeto& objeto,
                                          RelatorioDeGeracao& relatorio);

// A disciplina de pilha só funciona se cada temporário for lido EXATAMENTE uma
// vez: ler é desempilhar, e um segundo leitor encontraria a pilha já consumida.
// A representação intermediária do módulo 13 satisfaz isso por construção — ela
// reemite o casamento a cada uso em vez de reaproveitar o temporário —, mas
// "por construção" é exatamente o tipo de garantia que se perde numa alteração
// futura. A verificação custa um percurso e transforma um defeito silencioso de
// execução em erro de compilação.
std::vector<std::string> verificarUsoUnico(const CodigoRI& ri);

// ---------------------------------------------------------------------------
// Alocação de registradores, calculada (tratamento conceitual, número real)
// ---------------------------------------------------------------------------

// A faixa de vida de um temporário: da instrução que o define à última que o
// lê. Dois temporários INTERFEREM quando as faixas se sobrepõem — eles não
// podem compartilhar registrador.
struct FaixaDeVida {
    std::string temporario;
    std::size_t definicao = 0;
    std::size_t ultimoUso = 0;
};

struct GrafoDeInterferencia {
    std::vector<FaixaDeVida> faixas;
    // Matriz de adjacência achatada: interfere[i * n + j].
    std::vector<std::uint8_t> interfere;
    std::size_t n = 0;

    bool haAresta(std::size_t i, std::size_t j) const;
};

GrafoDeInterferencia construirInterferencia(const CodigoRI& ri);

// Coloração gulosa: percorre os temporários e dá a cada um a menor cor que
// nenhum vizinho usa. Não é ótima em geral — colorir com o número mínimo de
// cores é problema difícil, e é por isso que compiladores reais usam heurística
// —, mas em grafos de intervalo como estes ela encontra o mínimo.
struct Coloracao {
    std::vector<std::size_t> corDoTemporario;
    std::size_t cores = 0;
};

Coloracao colorir(const GrafoDeInterferencia& g);

// Grava o objeto em disco, para inspeção. Devolve false e preenche `erro` em
// caso de falha.
bool gravarObjeto(const ProgramaObjeto& objeto, const std::string& caminho,
                  std::string& erro);

}  // namespace peneira

#endif  // PENEIRA_14_CODEGEN_H

A demonstração de seleção põe as duas formas lado a lado, com o código de três endereços à esquerda e o de pilha à direita, para a mesma regra:

  tres enderecos (maquina de registradores infinitos)   pilha
  ----------------------------------------------------  --------------------
      0: t0 := casamento n                              0: PUSH_MATCH 0
      1: t1 := valor t0                                 1: VALUE
      2: t2 := 100                                      2: PUSH_CONST 0
      3: t3 := t1 > t2                                  3: CMP_GT
      4: se_falso t3 desvia para 12                     4: JUMP_IF_FALSE 12
      5: t4 := casamento n                              5: PUSH_MATCH 0
      6: t5 := valor t4                                 6: VALUE
      7: t6 := 500                                      7: PUSH_CONST 1
      8: t7 := t5 < t6                                  8: CMP_LT
      9: se_falso t7 desvia para 12                     9: JUMP_IF_FALSE 12
     10: t8 := casamento n                             10: PUSH_MATCH 0
     11: emite "faixa", t8                             11: EMIT 2

Repare no que a coluna da direita não tem: nomes. O código de três endereços é, como argumentei na Seção 2.2, código para uma máquina de registradores infinitos, e cada temporário é um registrador virtual. A máquina de pilha apaga todos eles e deixa a posição fazer o trabalho que o nome fazia.

A simplicidade dessa coluna foi comprada, e o preço está pago no capítulo anterior: o objeto executa mais instruções do que executaria numa máquina de registradores, porque empilhar e desempilhar são trabalho que o registrador nomeado dispensa. É exatamente a redistribuição da tabela da Seção 2.3, vista em um caso concreto.

Sobre a invariante de leitura única da Seção 4.3: a representação intermediária deste compilador a satisfaz por construção, porque ela reemite o casamento a cada uso em vez de reaproveitar o temporário — é por isso que a extração de valor aparece duas vezes na condição acima e gera dois empilhamentos do texto reconhecido. Mesmo assim, escrevi a verificação em código, pela razão que defendi naquela seção: “por construção” é precisamente o tipo de garantia que se perde numa alteração futura. A verificação percorre a representação contando leituras por temporário, e uma violação vira erro de compilação em vez de defeito silencioso de execução.

Registro a armadilha que a Seção 4.3 antecipa, porque eu a encontrei ao escrever: contar como leitura de temporário todo campo de operando produz nomes fantasmas, porque o campo de operando de uma constante é um literal e não um temporário. A verificação despacha por tipo de operação em vez de varrer os campos.

1.10.3 9.3 O mapa de endereços e a segunda passagem

O curto-circuito, neste compilador, já vem resolvido do capítulo anterior — as listas de desvios da Seção 5.3 foram preenchidas lá, e os desvios chegam aqui com destino definido. O que não vem resolvido é que aqueles destinos são índices da representação intermediária, e o objeto precisa de índices do objeto. É o problema da Seção 6.2, na sua forma exata.

14_codegen.cpp
#include "14_codegen.h"

#include <fstream>
#include <unordered_map>

namespace peneira {

namespace {

// Seleção de instruções: a tabela que mapeia operador de comparação da fonte
// para o código da máquina. É literalmente a "seleção de instruções" do
// módulo, e ela cabe numa função porque a máquina foi projetada para isso.
OpCode selecionarComparacao(const std::string& operador) noexcept {
    if (operador == ">")  return OpCode::CMP_GT;
    if (operador == "<")  return OpCode::CMP_LT;
    if (operador == ">=") return OpCode::CMP_GE;
    if (operador == "<=") return OpCode::CMP_LE;
    if (operador == "==") return OpCode::CMP_EQ;
    if (operador == "!=") return OpCode::CMP_NE;
    return OpCode::CMP_EQ;
}

// Efeito de cada instrução sobre a altura da pilha. Serve para calcular a
// profundidade máxima por simulação estática, sem executar.
int efeitoNaPilha(OpCode op) noexcept {
    switch (op) {
        case OpCode::PUSH_CONST:
        case OpCode::PUSH_MATCH:
            return +1;
        case OpCode::VALUE:
            return 0;  // desempilha um, empilha um
        case OpCode::CMP_GT:
        case OpCode::CMP_LT:
        case OpCode::CMP_GE:
        case OpCode::CMP_LE:
        case OpCode::CMP_EQ:
        case OpCode::CMP_NE:
        case OpCode::AND:
        case OpCode::OR:
            return -1;  // desempilha dois, empilha um
        case OpCode::JUMP_IF_FALSE:
        case OpCode::EMIT:
            return -1;
        case OpCode::JUMP:
        case OpCode::HALT:
            return 0;
    }
    return 0;
}

void colherAcoes(const NoAst& no, std::vector<const NoAst*>& saida) {
    if (no.tipo == TipoAst::Acao) {
        saida.push_back(&no);
        return;
    }
    for (const AstPtr& filho : no.filhos) {
        if (filho) {
            colherAcoes(*filho, saida);
        }
    }
}

}  // namespace

std::vector<std::string> verificarUsoUnico(const CodigoRI& ri) {
    std::unordered_map<std::string, std::size_t> leituras;

    for (const InstrucaoRI& ins : ri.instrucoes) {
        // Os campos que CONTÊM leitura de temporário variam por operação, e
        // listá-los explicitamente é mais seguro que varrer todos os campos:
        // `arg1` de uma constante é um literal, não um temporário.
        switch (ins.op) {
            case OpRI::Valor:
                ++leituras[ins.arg1];
                break;
            case OpRI::Comparacao:
                ++leituras[ins.arg1];
                ++leituras[ins.arg2];
                break;
            case OpRI::DesvioSeFalso:
                ++leituras[ins.arg1];
                break;
            case OpRI::Emite:
                ++leituras[ins.arg2];
                break;
            default:
                break;
        }
    }

    std::vector<std::string> violacoes;
    for (const InstrucaoRI& ins : ri.instrucoes) {
        const bool define = ins.op == OpRI::Constante ||
                            ins.op == OpRI::CasamentoDe ||
                            ins.op == OpRI::Valor ||
                            ins.op == OpRI::Comparacao;
        if (!define) {
            continue;
        }
        const auto it = leituras.find(ins.resultado);
        const std::size_t vezes = it == leituras.end() ? 0 : it->second;
        if (vezes > 1) {
            violacoes.push_back(ins.resultado + " lido " +
                                std::to_string(vezes) + " vezes");
        }
    }
    return violacoes;
}

std::vector<Instrucao> gerarCodigoDaRegra(const CodigoRI& ri,
                                          ProgramaObjeto& objeto,
                                          RelatorioDeGeracao& relatorio) {
    std::vector<Instrucao> codigo;

    // O mapa de endereços. A representação intermediária desvia para ÍNDICES
    // DELA MESMA, e o objeto precisa desviar para índices DO OBJETO. Como uma
    // instrução intermediária pode virar zero, uma ou várias instruções de
    // máquina, os dois numeradores não coincidem em geral, e supor que
    // coincidem é o erro que produz desvio para o meio de outra instrução.
    //
    // O mapa tem um elemento A MAIS que o número de instruções: o índice
    // "logo depois da última" é destino legítimo — é para lá que apontam os
    // desvios de condição falsa.
    std::vector<std::size_t> enderecoDe(ri.instrucoes.size() + 1, 0);

    for (std::size_t i = 0; i < ri.instrucoes.size(); ++i) {
        enderecoDe[i] = codigo.size();
        const InstrucaoRI& ins = ri.instrucoes[i];

        switch (ins.op) {
            case OpRI::Constante: {
                // O literal vai para a área de constantes; a instrução carrega
                // o índice. Reaproveitamento é automático: constante repetida
                // ocupa uma entrada só.
                std::string valor = ins.arg1;
                if (valor.size() >= 2 && valor.front() == '"' &&
                    valor.back() == '"') {
                    valor = valor.substr(1, valor.size() - 2);
                }
                codigo.push_back(Instrucao{OpCode::PUSH_CONST,
                                           objeto.adicionarConstante(valor)});
                break;
            }
            case OpRI::CasamentoDe:
                // Argumento 0: cada regra tem exatamente uma ligação nesta
                // linguagem. O campo existe para o dia em que houver mais.
                codigo.push_back(Instrucao{OpCode::PUSH_MATCH, 0});
                break;
            case OpRI::Valor:
                codigo.push_back(Instrucao{OpCode::VALUE, 0});
                break;
            case OpRI::Comparacao:
                codigo.push_back(
                    Instrucao{selecionarComparacao(ins.operador), 0});
                break;
            case OpRI::DesvioSeFalso:
                // Destino ainda desconhecido em endereços de objeto: fica zero
                // e é corrigido na passagem seguinte.
                codigo.push_back(Instrucao{OpCode::JUMP_IF_FALSE, 0});
                break;
            case OpRI::Desvio:
                codigo.push_back(Instrucao{OpCode::JUMP, 0});
                break;
            case OpRI::Emite: {
                std::string rotulo = ins.arg1;
                if (rotulo.size() >= 2 && rotulo.front() == '"' &&
                    rotulo.back() == '"') {
                    rotulo = rotulo.substr(1, rotulo.size() - 2);
                }
                codigo.push_back(Instrucao{OpCode::EMIT,
                                           objeto.adicionarConstante(rotulo)});
                break;
            }
            case OpRI::Rotulo:
                // Não emite instrução nenhuma: o rótulo é posição, e a posição
                // já está registrada no mapa. É o caso que torna o mapa
                // necessário — aqui o numerador do objeto NÃO avança.
                break;
        }
    }
    enderecoDe[ri.instrucoes.size()] = codigo.size();

    // Toda regra termina em HALT explícito. Poderia ser implícito — passar da
    // última instrução encerra —, e deixar explícito custa um byte e elimina
    // um caso especial do executor.
    codigo.push_back(Instrucao{OpCode::HALT, 0});

    // Segunda passagem: resolver as referências pendentes. Agora os dois
    // numeradores são conhecidos, e a tradução de um para o outro é o mapa.
    std::size_t resolvidas = 0;
    for (std::size_t i = 0; i < ri.instrucoes.size(); ++i) {
        const InstrucaoRI& ins = ri.instrucoes[i];
        const bool ehDesvio =
            ins.op == OpRI::Desvio || ins.op == OpRI::DesvioSeFalso;
        if (!ehDesvio) {
            continue;
        }
        if (ins.destino == kDestinoPendente ||
            ins.destino > ri.instrucoes.size()) {
            // Desvio sem destino chegou até aqui: é defeito do módulo 13, e
            // gerar código a partir dele produziria objeto malformado.
            continue;
        }
        codigo[enderecoDe[i]].argumento =
            static_cast<std::uint32_t>(enderecoDe[ins.destino]);
        ++resolvidas;
    }
    relatorio.referenciasResolvidas += resolvidas;

    // Profundidade máxima da pilha, por simulação estática. Percorrer
    // linearmente é aproximação segura aqui porque todo desvio salta para a
    // frente e os dois caminhos chegam com a mesma altura — propriedade que a
    // tradução do módulo 13 garante e que uma tradução futura poderia quebrar.
    int altura = 0;
    int maximo = 0;
    for (const Instrucao& ins : codigo) {
        altura += efeitoNaPilha(ins.op);
        if (altura > maximo) {
            maximo = altura;
        }
    }
    if (static_cast<std::size_t>(maximo) > relatorio.profundidadeMaximaDaPilha) {
        relatorio.profundidadeMaximaDaPilha = static_cast<std::size_t>(maximo);
    }

    relatorio.instrucoesEmitidas += codigo.size();
    return codigo;
}

ProgramaObjeto gerarObjeto(const ResultadoSemantico& semantico,
                           const NoAst& raiz, RelatorioDeGeracao& relatorio) {
    ProgramaObjeto objeto;

    // Seção 1: os autômatos. É aqui que o conteúdo do primeiro bloco do
    // semestre entra, literalmente, dentro do arquivo gerado.
    std::unordered_map<std::string, std::uint32_t> indiceDoPadrao;
    for (const PadraoCompilado& p : semantico.padroes) {
        indiceDoPadrao.emplace(
            p.nome, static_cast<std::uint32_t>(objeto.padroes.size()));
        objeto.padroes.push_back(serializarPadrao(
            p.automato, p.nome, p.tipoDoCasamento == Tipo::Numero));
    }

    // Seção 2: o código das regras.
    std::vector<const NoAst*> acoes;
    colherAcoes(raiz, acoes);

    for (const NoAst* acao : acoes) {
        TradutorRI tradutor;
        const CodigoRI ri = tradutor.traduzirAcao(*acao);

        const std::vector<std::string> violacoes = verificarUsoUnico(ri);
        for (const std::string& v : violacoes) {
            relatorio.violacoesDeUsoUnico.push_back(acao->texto + ": " + v);
        }

        RegraObjeto regra;
        const auto it = indiceDoPadrao.find(acao->texto);
        if (it == indiceDoPadrao.end()) {
            // Padrão inexistente já foi reportado pela análise semântica; aqui
            // a ação é simplesmente pulada, para não emitir objeto que
            // referencia padrão que não existe.
            continue;
        }
        regra.indiceDoPadrao = it->second;
        regra.ligacao = acao->conteudo;
        regra.codigo = gerarCodigoDaRegra(ri, objeto, relatorio);
        objeto.regras.push_back(std::move(regra));
        ++relatorio.regrasGeradas;
    }

    return objeto;
}

// ---------------------------------------------------------------------------
// Interferência e coloração
// ---------------------------------------------------------------------------

bool GrafoDeInterferencia::haAresta(std::size_t i, std::size_t j) const {
    if (i >= n || j >= n) {
        return false;
    }
    return interfere[i * n + j] != 0;
}

GrafoDeInterferencia construirInterferencia(const CodigoRI& ri) {
    GrafoDeInterferencia g;
    std::unordered_map<std::string, std::size_t> indice;

    // Faixa de vida: da definição ao último uso.
    for (std::size_t i = 0; i < ri.instrucoes.size(); ++i) {
        const InstrucaoRI& ins = ri.instrucoes[i];
        const bool define = ins.op == OpRI::Constante ||
                            ins.op == OpRI::CasamentoDe ||
                            ins.op == OpRI::Valor ||
                            ins.op == OpRI::Comparacao;
        if (define && !ins.resultado.empty()) {
            indice.emplace(ins.resultado, g.faixas.size());
            g.faixas.push_back(FaixaDeVida{ins.resultado, i, i});
        }

        auto registrarUso = [&](const std::string& nome) {
            const auto it = indice.find(nome);
            if (it != indice.end()) {
                g.faixas[it->second].ultimoUso = i;
            }
        };

        switch (ins.op) {
            case OpRI::Valor:
            case OpRI::DesvioSeFalso:
                registrarUso(ins.arg1);
                break;
            case OpRI::Comparacao:
                registrarUso(ins.arg1);
                registrarUso(ins.arg2);
                break;
            case OpRI::Emite:
                registrarUso(ins.arg2);
                break;
            default:
                break;
        }
    }

    g.n = g.faixas.size();
    g.interfere.assign(g.n * g.n, 0);
    for (std::size_t i = 0; i < g.n; ++i) {
        for (std::size_t j = i + 1; j < g.n; ++j) {
            // Sobreposição em intervalo SEMIABERTO, e a diferença em relação ao
            // fechado não é detalhe: um valor MORRE na instrução que o lê pela
            // última vez, e o valor que essa mesma instrução produz pode ocupar
            // o registrador que acabou de vagar.
            //
            // Com intervalo fechado, `t3 := t1 > t2` faria t3 interferir com t1
            // e t2, e a conta pediria um registrador a mais do que o necessário.
            // Foi exatamente o que a primeira versão desta função respondeu — 3
            // registradores para uma expressão de profundidade 2 —, e o erro só
            // apareceu porque o número foi confrontado com a altura da pilha.
            const bool sobrepoe = g.faixas[i].definicao < g.faixas[j].ultimoUso &&
                                  g.faixas[j].definicao < g.faixas[i].ultimoUso;
            if (sobrepoe) {
                g.interfere[i * g.n + j] = 1;
                g.interfere[j * g.n + i] = 1;
            }
        }
    }
    return g;
}

Coloracao colorir(const GrafoDeInterferencia& g) {
    Coloracao c;
    c.corDoTemporario.assign(g.n, 0);

    for (std::size_t i = 0; i < g.n; ++i) {
        std::vector<bool> usada(g.n + 1, false);
        for (std::size_t j = 0; j < g.n; ++j) {
            if (j != i && g.haAresta(i, j) && j < i) {
                usada[c.corDoTemporario[j]] = true;
            }
        }
        std::size_t cor = 0;
        while (cor < usada.size() && usada[cor]) {
            ++cor;
        }
        c.corDoTemporario[i] = cor;
        if (cor + 1 > c.cores) {
            c.cores = cor + 1;
        }
    }
    return c;
}

bool gravarObjeto(const ProgramaObjeto& objeto, const std::string& caminho,
                  std::string& erro) {
    std::ofstream saida(caminho, std::ios::binary);
    if (!saida) {
        erro = "nao foi possivel abrir para escrita";
        return false;
    }
    // Forma textual, e não binária. A decisão é didática e assumida: o objeto
    // deste compilador é para ser LIDO na correção e na aula. Um formato
    // binário seria menor e ilegível, e a economia não tem valor aqui.
    saida << formatarObjeto(objeto);
    if (!saida) {
        erro = "falha ao escrever";
        return false;
    }
    return true;
}

}  // namespace peneira

A solução é o mapa construído durante a emissão e consultado numa segunda passagem, com os dois detalhes que a Seção 6.2 destacou: o elemento a mais, porque “logo depois da última” é destino legítimo e é para lá que apontam os desvios de condição falsa; e o fato de a operação de rótulo não emitir instrução alguma, ponto em que o contador da representação avança e o do objeto não.

A demonstração confere o resultado, e ele é honesto de um jeito que vale registrar:

  faixa.pen, regra on numero(n):
    instrucoes intermediarias: 12, de maquina: 13
    referencias resolvidas: 2
      RI 4 desvia para RI 12  ->  objeto 4 desvia para 12
      RI 9 desvia para RI 12  ->  objeto 9 desvia para 12

A correspondência saiu um para um neste artefato, porque toda instrução intermediária virou exatamente uma de máquina. Eu poderia ter concluído dali que o mapa é desnecessário e escrito o gerador supondo identidade — e teria caído exatamente na armadilha da Seção 6.3. A coincidência é propriedade deste conjunto de instruções, não garantia. A operação de rótulo já quebraria a identidade se fosse usada, e qualquer instrução futura que precise de duas de máquina também. Supor identidade produz desvio para a instrução errada, defeito que não aparece em teste algum até a entrada certa aparecer.

1.10.4 9.4 A alocação de registradores, calculada — e o defeito que o cálculo revelou

Esta máquina não tem registradores, e o assunto da Seção 7 poderia, portanto, ficar no verbal. Preferi calcular o que ele custaria: montar o grafo de interferência dos temporários da representação intermediária e colori-lo. O número de cores é o número de registradores que uma máquina de registradores precisaria para a mesma regra.

O resultado, para a regra do exemplo anterior:

    temporario | definido em | ultimo uso | cor
    -----------+-------------+------------+----
        t0     |      0      |     1      |  0
        t1     |      1      |     3      |  0
        t2     |      2      |     3      |  1
        t3     |      3      |     4      |  0
        ...
  temporarios: 9, arestas de interferencia: 2
  registradores necessarios (cores): 2
  profundidade maxima da pilha:      2

Dois registradores, e profundidade de pilha dois. Os números coincidem, e a Seção 7.6 explica por que não é acaso: com cada temporário lido uma única vez, o número de valores vivos ao mesmo tempo é a altura da pilha naquele ponto. As duas máquinas precisam da mesma quantidade de espaço de trabalho; a diferença está em quem o endereça.

Agora o que interessa. A primeira versão desta rotina respondeu três registradores contra pilha de dois, e é esse desencontro que quero registrar.

A causa é o erro clássico da Seção 7.5. Eu tratava as faixas de vida como intervalos fechados, de modo que, na instrução que compara dois temporários e produz um terceiro, o resultado interferia com os dois operandos. Como argumentei ali, um valor morre na instrução que o lê pela última vez, e o valor que essa mesma instrução produz pode ocupar o registrador que acabou de vagar. A sobreposição correta é em intervalo semiaberto, e a correção foi trocar duas comparações.

O que quero deixar registrado não é o erro; é o mecanismo que o pegou. Três registradores para uma expressão de profundidade dois é um número perfeitamente plausível — não tem cara de defeito, nenhum teste de compilação o acusaria, e o compilador não reclama. Ele só caiu porque havia um segundo caminho independente para chegar à mesma grandeza, a simulação estática da pilha, e os dois foram impressos lado a lado. É o mesmo padrão que usei ao confrontar o autômato gerado com o desenhado à mão, e ao testar o autômato mínimo obtido por duas vias com um teste de isomorfismo. Calcular a mesma coisa de dois jeitos independentes é a técnica de verificação mais barata deste projeto, e é a que mais defeitos encontrou.

1.10.5 9.5 O executor mínimo

“Mínimo” tem significado preciso aqui: suficiente para demonstrar que o objeto está correto. O tratamento completo de erro e de casos de fronteira é do capítulo seguinte, e superdimensionar o executor agora atrasaria a única coisa que ele precisa provar.

14_vm.h
#ifndef PENEIRA_14_VM_H
#define PENEIRA_14_VM_H

#include <cstddef>
#include <string>
#include <vector>

#include "13_objeto.h"

namespace peneira {

// EXECUTOR MÍNIMO do programa objeto.
//
// Mínimo tem significado preciso aqui: suficiente para demonstrar que o objeto
// gerado está correto. O tratamento completo de erro e de casos de fronteira é
// do módulo 15, e superdimensionar este executor agora atrasaria a única coisa
// que ele precisa provar — que o que o gerador emitiu executa e produz o efeito
// esperado.
//
// Ele é escrito contra a ESPECIFICAÇÃO do módulo 13, e não contra o gerador do
// módulo 14. A diferença importa: escrito contra o gerador, ele concordaria com
// os defeitos do gerador. Escrito contra a especificação, uma divergência entre
// os dois acusa que um dos dois está errado — que é justamente o teste que o
// critério de completude da especificação queria permitir.

// Um valor na pilha de avaliação. A linguagem tem dois tipos, e o executor
// carrega os dois numa estrutura só porque a análise semântica já garantiu que
// nenhuma instrução vai receber o tipo errado.
struct Valor {
    bool ehNumero = false;
    double numero = 0.0;
    std::string texto;
};

// Uma emissão produzida pela execução: o par (rótulo, valor) que `emit` gera.
struct Emissao {
    std::string rotulo;
    std::string valor;
    std::size_t posicao = 0;  // onde na entrada o casamento começou
};

struct ResultadoExecucao {
    std::vector<Emissao> emissoes;
    std::size_t casamentos = 0;
    std::size_t bytesLidos = 0;
    std::size_t instrucoesExecutadas = 0;
    std::vector<std::string> erros;
};

// Executa o programa objeto sobre a entrada.
//
// O laço principal implementa o casamento mais longo especificado no módulo 13:
// na posição corrente, avança todos os autômatos em paralelo enquanto algum
// tiver transição, lembrando a última posição em que algum esteve em estado
// final. Empate no mesmo comprimento resolve-se pela ordem de declaração — o
// primeiro padrão declarado vence, exatamente como está escrito na
// especificação.
ResultadoExecucao executar(const ProgramaObjeto& objeto,
                           const std::string& entrada);

}  // namespace peneira

#endif  // PENEIRA_14_VM_H

A decisão de método vale mais que o código, e é a da Seção 8.6: escrevi o executor contra a especificação do formato, e não contra o gerador. A diferença não é retórica. Escrito contra o gerador, ele concordaria com os defeitos do gerador — os dois estariam errados juntos e os testes passariam. Escrito contra a especificação, uma divergência entre gerador e executor acusa que um dos dois está errado. É exatamente o teste que o critério de completude existia para tornar possível, e é a razão de a especificação ter vindo antes.

14_vm.cpp
#include "14_vm.h"

#include <cstdlib>

namespace peneira {

namespace {

// Consulta a tabela de transição densa. É a operação do laço interno — roda uma
// vez por byte da entrada e por padrão —, e é ela que justifica a matriz densa
// escolhida no módulo 13.
std::uint32_t transicaoDe(const PadraoObjeto& p, std::uint32_t estado,
                          unsigned char simbolo) noexcept {
    const std::size_t indice =
        static_cast<std::size_t>(estado) * kTamanhoDoAlfabeto +
        static_cast<std::size_t>(simbolo);
    if (indice >= p.transicoes.size()) {
        return kSemTransicao;
    }
    return p.transicoes[indice];
}

// Verdade nesta linguagem: número diferente de zero, ou texto não vazio. A
// definição precisa estar num lugar só, porque JUMP_IF_FALSE e os conectivos a
// consultam.
bool ehVerdadeiro(const Valor& v) noexcept {
    return v.ehNumero ? v.numero != 0.0 : !v.texto.empty();
}

std::string comoTexto(const Valor& v) {
    if (!v.ehNumero) {
        return v.texto;
    }
    // Sem casas decimais quando o número é inteiro: 250 e não 250.000000. É
    // cosmético e afeta a saída observável, então está aqui e não no chamador.
    const double arredondado = static_cast<double>(static_cast<long long>(v.numero));
    if (v.numero == arredondado) {
        return std::to_string(static_cast<long long>(v.numero));
    }
    return std::to_string(v.numero);
}

// Executa o código de uma regra sobre um casamento. Devolve false se o objeto
// estiver malformado — pilha vazia numa instrução que desempilha, ou desvio
// fora de faixa. As duas são as condições de erro listadas na especificação.
bool executarRegra(const ProgramaObjeto& objeto, const RegraObjeto& regra,
                   const std::string& casamento, std::size_t posicao,
                   ResultadoExecucao& resultado) {
    std::vector<Valor> pilha;
    std::size_t pc = 0;

    auto desempilhar = [&](Valor& destino) -> bool {
        if (pilha.empty()) {
            resultado.erros.push_back(
                "objeto malformado: pilha vazia em instrucao que desempilha");
            return false;
        }
        destino = pilha.back();
        pilha.pop_back();
        return true;
    };

    while (pc < regra.codigo.size()) {
        const Instrucao& ins = regra.codigo[pc];
        ++resultado.instrucoesExecutadas;

        switch (ins.op) {
            case OpCode::PUSH_CONST: {
                if (ins.argumento >= objeto.constantes.size()) {
                    resultado.erros.push_back(
                        "objeto malformado: constante fora de faixa");
                    return false;
                }
                Valor v;
                // A constante é guardada como texto; se ela denota um número,
                // entra como número. A análise semântica já garantiu a
                // coerência de tipos, então esta conversão não pode
                // surpreender.
                const std::string& bruto = objeto.constantes[ins.argumento];
                char* fim = nullptr;
                const double numero = std::strtod(bruto.c_str(), &fim);
                if (fim != nullptr && *fim == '\0' && !bruto.empty()) {
                    v.ehNumero = true;
                    v.numero = numero;
                } else {
                    v.texto = bruto;
                }
                pilha.push_back(v);
                break;
            }

            case OpCode::PUSH_MATCH: {
                Valor v;
                v.texto = casamento;
                pilha.push_back(v);
                break;
            }

            case OpCode::VALUE: {
                Valor v;
                if (!desempilhar(v)) {
                    return false;
                }
                Valor n;
                n.ehNumero = true;
                n.numero = v.ehNumero ? v.numero : std::strtod(v.texto.c_str(), nullptr);
                pilha.push_back(n);
                break;
            }

            case OpCode::CMP_GT:
            case OpCode::CMP_LT:
            case OpCode::CMP_GE:
            case OpCode::CMP_LE:
            case OpCode::CMP_EQ:
            case OpCode::CMP_NE: {
                // A ordem importa: o segundo operando foi empilhado por
                // último, então sai primeiro. Inverter aqui produz comparações
                // trocadas que passam despercebidas nos casos simétricos.
                Valor b;
                Valor a;
                if (!desempilhar(b) || !desempilhar(a)) {
                    return false;
                }
                bool r = false;
                if (a.ehNumero && b.ehNumero) {
                    switch (ins.op) {
                        case OpCode::CMP_GT: r = a.numero >  b.numero; break;
                        case OpCode::CMP_LT: r = a.numero <  b.numero; break;
                        case OpCode::CMP_GE: r = a.numero >= b.numero; break;
                        case OpCode::CMP_LE: r = a.numero <= b.numero; break;
                        case OpCode::CMP_EQ: r = a.numero == b.numero; break;
                        default:             r = a.numero != b.numero; break;
                    }
                } else {
                    const std::string ta = comoTexto(a);
                    const std::string tb = comoTexto(b);
                    switch (ins.op) {
                        case OpCode::CMP_EQ: r = ta == tb; break;
                        case OpCode::CMP_NE: r = ta != tb; break;
                        default:
                            // Ordem sobre texto foi recusada na analise
                            // semantica, entao chegar aqui e objeto adulterado.
                            resultado.erros.push_back(
                                "objeto malformado: operador de ordem sobre texto");
                            return false;
                    }
                }
                Valor v;
                v.ehNumero = true;
                v.numero = r ? 1.0 : 0.0;
                pilha.push_back(v);
                break;
            }

            case OpCode::AND:
            case OpCode::OR: {
                Valor b;
                Valor a;
                if (!desempilhar(b) || !desempilhar(a)) {
                    return false;
                }
                Valor v;
                v.ehNumero = true;
                const bool r = ins.op == OpCode::AND
                                   ? (ehVerdadeiro(a) && ehVerdadeiro(b))
                                   : (ehVerdadeiro(a) || ehVerdadeiro(b));
                v.numero = r ? 1.0 : 0.0;
                pilha.push_back(v);
                break;
            }

            case OpCode::JUMP_IF_FALSE: {
                Valor v;
                if (!desempilhar(v)) {
                    return false;
                }
                if (!ehVerdadeiro(v)) {
                    if (ins.argumento > regra.codigo.size()) {
                        resultado.erros.push_back(
                            "objeto malformado: desvio fora de faixa");
                        return false;
                    }
                    pc = ins.argumento;
                    continue;
                }
                break;
            }

            case OpCode::JUMP: {
                if (ins.argumento > regra.codigo.size()) {
                    resultado.erros.push_back(
                        "objeto malformado: desvio fora de faixa");
                    return false;
                }
                pc = ins.argumento;
                continue;
            }

            case OpCode::EMIT: {
                Valor v;
                if (!desempilhar(v)) {
                    return false;
                }
                if (ins.argumento >= objeto.constantes.size()) {
                    resultado.erros.push_back(
                        "objeto malformado: rotulo fora de faixa");
                    return false;
                }
                resultado.emissoes.push_back(Emissao{
                    objeto.constantes[ins.argumento], comoTexto(v), posicao});
                break;
            }

            case OpCode::HALT:
                return true;
        }
        ++pc;
    }
    return true;
}

}  // namespace

ResultadoExecucao executar(const ProgramaObjeto& objeto,
                           const std::string& entrada) {
    ResultadoExecucao resultado;
    resultado.bytesLidos = entrada.size();

    std::size_t posicao = 0;
    while (posicao < entrada.size()) {
        // Casamento mais longo: avança todos os autômatos em paralelo.
        std::vector<std::uint32_t> estados(objeto.padroes.size());
        std::vector<bool> vivo(objeto.padroes.size(), true);
        for (std::size_t p = 0; p < objeto.padroes.size(); ++p) {
            estados[p] = objeto.padroes[p].estadoInicial;
            if (objeto.padroes[p].quantidadeDeEstados == 0) {
                vivo[p] = false;
            }
        }

        std::size_t melhorFim = posicao;
        std::size_t melhorPadrao = objeto.padroes.size();
        bool houveCasamento = false;

        // Um padrão pode aceitar a cadeia vazia; conferir o estado inicial
        // antes de consumir byte nenhum evita perder esse caso — e evita
        // também o laço infinito que ele causaria se fosse aceito com
        // comprimento zero, tratado adiante.
        for (std::size_t p = 0; p < objeto.padroes.size(); ++p) {
            if (vivo[p] && objeto.padroes[p].finais[estados[p]] != 0) {
                houveCasamento = true;
                melhorFim = posicao;
                melhorPadrao = p;
                break;
            }
        }

        std::size_t i = posicao;
        while (i < entrada.size()) {
            const unsigned char simbolo = static_cast<unsigned char>(entrada[i]);
            bool algumVivo = false;
            for (std::size_t p = 0; p < objeto.padroes.size(); ++p) {
                if (!vivo[p]) {
                    continue;
                }
                const std::uint32_t proximo =
                    transicaoDe(objeto.padroes[p], estados[p], simbolo);
                if (proximo == kSemTransicao) {
                    vivo[p] = false;
                    continue;
                }
                estados[p] = proximo;
                algumVivo = true;
            }
            if (!algumVivo) {
                break;
            }
            ++i;
            // Registra o casamento mais longo visto até agora. A varredura é
            // da esquerda para a direita, e o desempate por ORDEM DE
            // DECLARAÇÃO sai de graça: o laço testa os padrões na ordem do
            // vetor e o `>` estrito impede que um padrão posterior substitua
            // um anterior de mesmo comprimento.
            for (std::size_t p = 0; p < objeto.padroes.size(); ++p) {
                if (vivo[p] && objeto.padroes[p].finais[estados[p]] != 0) {
                    if (!houveCasamento || i > melhorFim) {
                        houveCasamento = true;
                        melhorFim = i;
                        melhorPadrao = p;
                    }
                    break;
                }
            }
        }

        // Casamento de comprimento zero não faz progresso e travaria o laço.
        // Tratar como ausência de casamento é o que garante terminação.
        if (!houveCasamento || melhorFim == posicao ||
            melhorPadrao >= objeto.padroes.size()) {
            ++posicao;
            continue;
        }

        const std::string casado =
            entrada.substr(posicao, melhorFim - posicao);
        ++resultado.casamentos;

        // Todas as regras daquele padrão disparam, na ordem de declaração.
        for (const RegraObjeto& regra : objeto.regras) {
            if (regra.indiceDoPadrao != melhorPadrao) {
                continue;
            }
            if (!executarRegra(objeto, regra, casado, posicao, resultado)) {
                return resultado;
            }
        }

        posicao = melhorFim;
    }

    return resultado;
}

}  // namespace peneira

Três pontos do laço principal mereceram cuidado.

O casamento mais longo avança todos os autômatos em paralelo enquanto algum tiver transição, lembrando a última posição em que algum esteve em estado final. É a mesma regra do analisador léxico, e reusá-la é o que mantém a linguagem coerente consigo mesma: o critério com que a Peneira reconhece os seus próprios símbolos é o critério com que os programas escritos nela reconhecem padrões no texto de entrada.

O desempate por ordem de declaração sai praticamente de graça: o laço testa os padrões na ordem do vetor, e a comparação estrita impede que um padrão posterior substitua um anterior de mesmo comprimento. Uma decisão que custou uma linha de código e teria custado dias de confusão se não estivesse na especificação — e ela só entrou na especificação porque escrevê-la antes obrigou a decidir.

O casamento de comprimento zero é a armadilha da Seção 8.6, e ela apareceu de verdade. Um padrão que aceita a cadeia vazia casaria sem consumir nada, e a posição de leitura nunca avançaria: o executor trava sem erro, sem mensagem e sem sintoma visível. Tratar comprimento zero como ausência de casamento é o que garante a terminação, e está no código com comentário porque não é dedutível de olhar.

A ordem dos operandos nas comparações é o quarto ponto, e é o da Seção 3.1: o segundo operando foi empilhado por último e sai primeiro. Inverter aqui produz comparações trocadas que passam despercebidas em todos os casos simétricos — igualdade e desigualdade continuam certas, e só as comparações de ordem denunciam.

1.10.6 9.6 Os três programas, compilados e executados

Escolhi três programas que exercitam coisas diferentes, porque três programas parecidos demonstram uma coisa só. A saída literal da demonstração:

  faixa.pen — curto-circuito do 'and' e desvios para a frente
    entrada: valores: 7 250 1200 480 -3 99 101
    saida:
      faixa = 250   (posicao 11)
      faixa = 480   (posicao 20)
      faixa = 101   (posicao 30)
    casamentos: 7, instrucoes executadas: 68, bytes: 33

  extremo.pen — curto-circuito do 'or', com desvio incondicional
    entrada: leituras 5 -12 1500 800 -1 2000
    saida:
      extremo = -12   (posicao 11)
      extremo = 1500   (posicao 15)
      extremo = -1   (posicao 24)
      extremo = 2000   (posicao 27)
    casamentos: 6, instrucoes executadas: 66, bytes: 31

  contatos.pen — dois padroes competindo, casamento mais longo
    entrada: ana@teste.com 42 bruno@x.org 350
    saida:
      contato = ana@teste.com   (posicao 0)
      contato = bruno@x.org   (posicao 17)
      grande = 350   (posicao 29)
    casamentos: 4, instrucoes executadas: 20, bytes: 32

Conferi as três à mão, e é a conferência que dá valor à demonstração.

No primeiro, dos sete números da entrada, apenas 250, 480 e 101 estão estritamente entre 100 e 500; os outros quatro falham por um dos dois lados, e três deles falham já na primeira comparação, com a segunda extração de valor nunca avaliada. É o curto-circuito funcionando, e ele é observável exatamente onde a Seção 5.5 disse que seria: na contagem de instruções. Sessenta e oito instruções para sete casamentos dá menos de dez por casamento, quando o caminho completo tem doze.

No segundo, a condição é disjuntiva e os quatro extremos saem: dois negativos e dois acima de mil. Repare que o valor negativo casa com o sinal, porque a expressão regular do padrão o inclui — se o padrão fosse só de dígitos, o executor casaria o número sem o sinal, a condição daria falso, e o defeito pareceria ser da condição quando seria do padrão. É o tipo de confusão que a competição entre camadas produz e que só a conferência manual desfaz.

No terceiro, os dois padrões competem sobre a mesma entrada e o casamento mais longo decide: o endereço casa inteiro, e não como uma sequência de pedaços. E um dos números casa e não emite, porque a regra tem condição, enquanto o outro casa e emite. As duas regras convivem sobre padrões diferentes.

O comando principal do compilador passa a fazer o ciclo completo:

> peneira exemplos/contatos.pen < exemplos/entrada.txt
fases do compilador:
  analise lexica      pronta
  analise sintatica   pronta
  analise semantica   pronta
  geracao de codigo   pronta
  execucao            minima
  padroes compilados: 2, regras: 2, instrucoes: 11
  objeto gravado ao lado do fonte

execucao sobre 33 bytes de entrada:
  contato   ana@teste.com
  contato   bruno@x.org
  grande    350
  3 emissao(oes) em 4 casamento(s)

O objeto é gravado em forma textual, pela razão da Seção 8.5: o objeto deste compilador existe para ser lido na conferência e em aula. Um formato binário seria menor e ilegível, e a economia não tem valor nesta escala.

Sobre o projeto dos casos de teste, seguindo o critério da Seção 5.5: cada entrada foi montada com casos que passam e casos que falham por cada motivo possível. Uma entrada só com números dentro da faixa não distinguiria curto-circuito de avaliação completa, e uma entrada sem endereços não testaria a competição entre padrões. E a saída esperada foi escrita antes de rodar — conferir depois é confirmar o que o programa fez, não verificar o que ele deveria fazer.

1.10.7 9.7 Os autômatos dentro do arquivo gerado

Encerro olhando a primeira seção do objeto, porque é aqui que a Seção 8.2 deixa de ser argumento e vira arquivo:

  tabela de padroes (2):
    [0] email: 6 estados, inicial 0, 1 final(is)
        celulas: 1536, preenchidas: 182
        casa somente numeros: nao
    [1] numero: 5 estados, inicial 0, 2 final(is)
        celulas: 1280, preenchidas: 52
        casa somente numeros: sim

Esses dois autômatos não são estruturas auxiliares do compilador. São o produto. Cada um deles percorreu o caminho inteiro do primeiro bloco do livro — a notação foi analisada, virou autômato não determinístico pela construção de Thompson, foi determinizada por subconjuntos e minimizada por refinamento de partições — e agora está gravado, como tabela de transição, no arquivo que o compilador escreveu.

E o número confirma a observação quantitativa da Seção 8.2: são 2816 células de transição contra 11 instruções. A maior parte do arquivo gerado, em bytes, é autômato. O código convencional é a minoria.

A linha que informa se o padrão reconhece somente números vem de mais longe ainda, e é o caso da Seção 8.4: ela é o resultado de uma decisão de inclusão de linguagens tomada na análise semântica, que por sua vez usou a diferença de autômatos e o teste de vacuidade. Uma informação de sistema de tipos, gravada no programa objeto, calculada por operações sobre autômatos.

Vale ver o compilador inteiro numa tabela, com o resultado de cada fase sobre o mesmo programa:

Fase Resultado neste programa
Análise léxica símbolos reconhecidos por autômato determinístico
Análise sintática 13 nós na árvore
Análise semântica 2 padrões, 0 erros
Representação intermediária três endereços com desvios
Geração de código 11 instruções, 2816 células de transição
Execução 3 emissões em 4 casamentos

É a tabela de interfaces do primeiro capítulo, preenchida com números reais de um compilador que existe.

1.11 Síntese

A geração de código é o ponto em que o compilador deixa de decidir e passa a escolher: o contrato de correção é o mesmo do primeiro módulo, e ele deixa de fora tudo o que interessa, porque infinitas sequências o satisfazem. Os três subproblemas interagem em ciclo, resolvê-los conjuntamente é intratável, e a decomposição troca otimalidade por tratabilidade e modularidade. A escolha entre pilha e registradores redistribui a dificuldade em vez de eliminá-la.

O conjunto de instruções é uma especificação, não uma lista de nomes, e da declaração do efeito decorre a altura estática da pilha. A emissão é um percurso em pós-ordem — consequência do modelo, não escolha — e depende da invariante de leitura única, porque numa máquina de pilha ler é desempilhar. O curto-circuito é semântica e não otimização, exprime-se com fluxo de controle, e a sua diferença aparece na contagem de instruções. As referências pendentes se resolvem registrando a posição e completando depois, com um mapa que precisa de uma entrada a mais. E a alocação de registradores reduz-se exatamente à coloração do grafo de interferência, herdando dela a NP-completude.

O produto é um arquivo com código e dados, cuja seção de dados pode conter tabelas de transição de autômatos determinísticos — e aí as máquinas dos primeiros módulos deixam de ser infraestrutura e passam a ser o resultado da compilação. Volto ao verificador caríssimo da abertura: ele agora escreve um arquivo, e dentro dele está a teoria com que o semestre começou.