Shopping cart

Subtotal $0.00

View cartCheckout

Building better devs

TnewsTnews
  • Home
  • Programação
  • Quer Escrever um Compilador? Dois Papers São Tudo que Você Precisa
Programação

Quer Escrever um Compilador? Dois Papers São Tudo que Você Precisa

Email : 28

Compiladores têm uma reputação injusta. Pergunte para qualquer dev o que acha de escrever um, e as respostas variam entre “impossível” e “coisa de pós-doutorado”. Essa percepção vem, em grande parte, de como o tema é ensinado nas faculdades: draões, parênteses, CFGs, gramáticas LALR(1), tabelas de parsing que parecem hieróglifos. Ninguém sai do semestre querendo escrever um compilador. Sai querendo nunca mais ouvir falar nisso.

Mas dois papers — um de 2006 e outro de 2013, ambos frequentemente referenciados como “os de 2008” no HN — vieram para desfazer esse mito. E o que eles ensinam é simples o suficiente para ser feito num final de semana: você não precisa construir o compilador inteiro antes de ter algo funcionando.

Essa ideia muda tudo.

O Paper que Desmitificou Compiladores

Em 2006, Abdulaziz Ghuloum publicou “An Incremental Approach to Compiler Construction” no Scheme Workshop. O argumento central é direto: a maioria das pessoas não consegue escrever um compilador porque tenta escrever o compilador inteiro de uma vez. Ghuloum propõe o oposto.

A abordagem incremental funciona assim: você começa com o compilador mais simples possível, que compila exatamente um tipo de expressão. Um número inteiro. Só isso. Esse compilador tem lexer, parser, geração de código — tudo funcionando, do começo ao fim. Então você adiciona mais um passo. Operações aritméticas. Depois variáveis. Depois condicionais. Em cada passo, você tem um compilador completo e funcional para um subconjunto da linguagem.

A diferença psicológica é enorme. Em vez de passar semanas sem ver nada funcionando, você tem código compilando e rodando no hardware depois das primeiras horas.

Passo 1: Um Compilador de 30 Linhas

Seguindo o método de Ghuloum, o primeiro compilador compila apenas inteiros para assembly x86-64:

# compiler_v1.py — compila apenas inteiros
import sys

def compile_program(expr):
    # Passo 1: parse (trivial para inteiros)
    value = int(expr.strip())
    
    # Passo 2: geração de código assembly x86-64
    return f"""
    .globl _start
_start:
    mov ${value}, %rax    # coloca o valor no registrador de retorno
    mov $60, %rdi         # syscall exit
    syscall
"""

source = sys.argv[1]
print(compile_program(source))

Ridiculamente simples. Mas funciona — compila um inteiro para assembly que pode ser executado diretamente. A partir daqui, você estende. Não reescreve. Estende.

O Segundo Paper: Nanopass

O segundo paper é “A Nanopass Framework for Commercial Compiler Development” de Andy Keep e R. Kent Dybvig (ICFP 2013 — a versão educacional é de 2004). O insight central aqui é diferente do de Ghuloum, mas complementar.

Compiladores industriais tendem a ter passes enormes — transformações que fazem muita coisa ao mesmo tempo e são difíceis de debugar, testar e manter. O framework nanopass propõe o extremo oposto: cada passo (pass) faz uma única coisa pequena. Um compilador nanopass pode ter 50 passes, mas cada um é trivial de entender isoladamente.

Pense assim:

Abordagem tradicionalNanopass
Pass enorme: otimização, inlining, alocação de registradores juntosPass 1: inline chamadas triviais
Pass 2: remover código morto
Pass 3: alocar registradores
Difícil de testar em isolamentoCada pass tem input/output bem definido e testável
Bug difícil de localizarBug localizado no pass específico
Mudança em um lugar afeta tudoPasses são independentes

Juntos, os dois papers ensinam a mesma coisa por ângulos diferentes: decomponha o problema. Verticalmente (incremental: compile menos primeiro) e horizontalmente (nanopass: faça menos por passo).

Como Funciona um Compilador de Verdade

Antes de ir para o código, vale solidificar o mapa mental. Um compilador moderno percorre estas etapas:

  1. Lexing (Análise Léxica): transforma texto bruto em tokens. 2 + 3 vira [NUM(2), PLUS, NUM(3)].
  2. Parsing (Análise Sintática): transforma tokens em AST (Abstract Syntax Tree). A árvore representa a estrutura lógica do programa.
  3. Análise Semântica: verifica tipos, escopos de variáveis, uso correto de funções. Onde a maioria dos erros de compilação vive.
  4. Geração de IR: produz uma representação intermediária, agnóstica de arquitetura (como LLVM IR ou bytecode).
  5. Otimização: passes de transformação no IR que melhoram performance.
  6. Geração de Código: produz assembly ou bytecode específico para o target.

A abordagem de Ghuloum pula algumas dessas etapas inicialmente e vai direto de parse para assembly. Funciona muito bem para aprendizado porque o feedback é imediato.

Implementando o Incremental na Prática

Vamos expandir o compilador do passo 1 para suportar adição e subtração:

# compiler_v2.py — agora suporta +, -, números
import sys
import re

# Lexer: transforma string em tokens
def lex(source):
    token_patterns = [
        ('NUM',   r'\d+'),
        ('PLUS',  r'\+'),
        ('MINUS', r'-'),
        ('LPAREN', r'\('),
        ('RPAREN', r'\)'),
        ('WS',    r'\s+'),  # whitespace, ignorar
    ]
    pattern = '|'.join(f'(?P{regex})' for name, regex in token_patterns)
    tokens = []
    for m in re.finditer(pattern, source):
        if m.lastgroup != 'WS':
            tokens.append((m.lastgroup, m.group()))
    return tokens

# Parser: tokens → AST (recursivo descendente)
class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0
    
    def peek(self):
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None
    
    def consume(self, expected=None):
        tok = self.tokens[self.pos]
        if expected and tok[0] != expected:
            raise SyntaxError(f"Esperava {expected}, encontrou {tok}")
        self.pos += 1
        return tok
    
    def parse_expr(self):
        left = self.parse_atom()
        while self.peek() and self.peek()[0] in ('PLUS', 'MINUS'):
            op = self.consume()[0]
            right = self.parse_atom()
            left = (op, left, right)
        return left
    
    def parse_atom(self):
        tok = self.peek()
        if tok[0] == 'NUM':
            self.consume()
            return ('NUM', int(tok[1]))
        raise SyntaxError(f"Token inesperado: {tok}")

# Code gen: AST → assembly x86-64
def codegen(node):
    if node[0] == 'NUM':
        return f"    mov ${node[1]}, %rax\n"
    
    left_code = codegen(node[1])
    right_code = codegen(node[2])
    
    if node[0] == 'PLUS':
        return (left_code +
                "    push %rax\n" +
                right_code +
                "    pop %rcx\n" +
                "    add %rcx, %rax\n")
    elif node[0] == 'MINUS':
        return (left_code +
                "    push %rax\n" +
                right_code +
                "    mov %rax, %rcx\n" +
                "    pop %rax\n" +
                "    sub %rcx, %rax\n")

source = sys.argv[1]
tokens = lex(source)
ast = Parser(tokens).parse_expr()
body = codegen(ast)

print(f"""    .globl _start
_start:
{body}    mov %rax, %rdi
    mov $60, %rax
    syscall
""")

Ainda cabe num arquivo. Ainda é completamente legível. E agora compila expressões como 2 + 3, 10 - 4 + 1. O próximo passo seria adicionar multiplicação, depois variáveis, depois funções. Cada um isolado e testável.

Por Que Isso Importa Para Devs “Normais”

Escrever um compilador completo para uma linguagem de produção é trabalho de anos. Mas entender como compiladores funcionam transforma a forma como você escreve código — em qualquer linguagem.

Quando você sabe que o compilador vai fazer constant folding, você escreve código mais limpo sem tentar “ajudar” o compilador de formas que só atrapalham. Quando entende como inlining funciona, você toma decisões melhores sobre funções pequenas vs. grandes. Quando sabe o que é um registrador e por que o compilador tenta mantê-lo ali, você entende por que certas abstrações custam mais do que parecem.

Além disso, há casos práticos onde você vai querer escrever algo parecido com um compilador:

  • DSLs (Domain Specific Languages) para configuração, queries, ou regras de negócio
  • Template engines que compilam templates para funções eficientes
  • Transpilers que convertem um subconjunto de código legado para código moderno
  • Linters e analisadores estáticos que constroem ASTs para analisar padrões
  • Formatadores de código como Prettier, que parseia e reprinta

Todas essas ferramentas usam as mesmas peças: lexer, parser, AST, transformações. A diferença entre um compilador “real” e um transpiler de DSL é apenas a complexidade — as técnicas são idênticas.

Por Onde Começar

Se você quiser realmente colocar a mão na massa, aqui está uma progressão honesta:

  1. Leia o paper do Ghuloum (18 páginas, gratuito): An Incremental Approach to Compiler Construction. Usa Scheme como linguagem de implementação, mas os conceitos se traduzem para qualquer linguagem.
  2. Implemente na linguagem que você conhece: Python, JavaScript, Go — todos funcionam. Não se prenda à linguagem do paper.
  3. Siga a progressão incremental: inteiros → aritmética → comparações → if/else → variáveis → funções. Um passo de cada vez.
  4. Escreva testes desde o início: cada passo novo tem casos de teste simples. Isso é o que o nanopass framework ensina: passes testáveis isoladamente.
  5. Não tente otimizar cedo: assembly ingênuo e correto é melhor do que assembly “otimizado” e bugado.

Recursos adicionais que valem o tempo:

  • Crafting Interpreters — Robert Nystrom (gratuito online). Interprete antes de compilar — os conceitos se sobrepõem muito.
  • My First Fifteen Compilers — SIGPLAN Blog. Uma retrospectiva honesta de alguém que escreveu muitos compiladores e errou bastante.
  • scheme-to-c — implementação de referência nanopass por Andy Keep.

O Que Muda Depois que Você Escreve Um

Tem uma frase que ouço de todo dev que escreveu seu primeiro compilador, mesmo um pequeno: “agora eu entendo o que o compilador está fazendo”. Não é exagero. A caixa preta vira vidro.

Error messages que antes pareciam aleatórias fazem sentido. A ordem das operações numa expression faz sentido. Por que certos padrões de código são mais rápidos que outros faz sentido. Por que o type checker do TypeScript às vezes parece fazer mágica — e às vezes parece quebrado — faz sentido.

E talvez mais importante: você percebe que as pessoas que escreveram GCC, LLVM, rustc ou javac são humanas. Tomaram decisões, às vezes erradas, evoluíram o código ao longo de anos, tiveram deadlines. O compilador que você usa todo dia não é a obra de gênios inacessíveis. É código escrito por pessoas que um dia também começaram com um parser de inteiros.


Referências: An Incremental Approach to Compiler Construction — Abdulaziz Ghuloum (2006) | A Nanopass Framework for Commercial Compiler Development — Andy Keep & R. Kent Dybvig (2013) | Crafting Interpreters — Robert Nystrom

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts