A máquina: como um .df executa
Do texto ao resultado — lexer, parser, análise, compilação para fechamentos e o interpretador de árvore.
Esta página responde o que acontece entre você apertar Enter e o programa dar resposta. Ela é honesta sobre a parte que mais gera pergunta: não há VM de bytecode, e a razão está medida mais abaixo.
arquivo.df → tokenize() → parse() → [check_program()] → run(ast)
lexer.py parser.py typechecker.py interpreter.pyAs cinco etapas#
| Etapa | Arquivo | O que entra e o que sai |
|---|---|---|
| léxico | lexer.py | texto → tokens, com INDENT/DEDENT e interpolação |
| sintaxe | parser.py | tokens → árvore, por descida recursiva |
| análise | typechecker.py | árvore → diagnósticos, sem executar nada |
| compilação | compilador.py | árvore → fechamentos Python, uma vez |
| execução | interpreter.py | fechamentos → o programa rodando |
A análise é opcional para rodar e obrigatória no check: é a mesma função nos dois casos, e é por isso que o editor, o CI e a linha de comando nunca discordam.
A indentação vira token#
Blocos por indentação exigem que o lexer conte colunas e emita marcas — não há chave para o parser casar:
given x > 5:
out "grande"
out "fim"GIVEN IDENT(x) GT INT(5) COLON NEWLINE
INDENT OUT STRING NEWLINE
DEDENT OUT STRING NEWLINE EOFTab é erro, e não equivalente a espaços: a mistura dos dois produz um arquivo que se lê de um jeito e executa de outro, conforme a largura do tab no editor de cada um.
Despacho: de string para tabela, e daí para fechamento#
O interpretador começou despachando por nome de classe — um nó GivenBlock procurava exec_GivenBlock. Funciona e é lento: é uma busca de atributo por instrução executada, e um programa médio executa mais de um milhão.
Hoje há duas camadas:
- Tabela por classe — o nome vira uma entrada de dicionário resolvida uma vez.
- Compilação para fechamentos — a árvore é percorrida uma vez e cada nó vira uma função Python que faz o que aquele nó faz. Executar passa a ser chamar funções: sem busca, sem
isinstance, sem ler campo de nó.
// o que a compilação faz, em espírito
// antes: a cada volta, olhar o nó e decidir o que ele é
// depois: uma função por nó, decidida uma vez
soma := 0
cycle i from 1 to 1000000:
soma += iMedido: 1,5× a 1,8×, conforme a carga. Três regras governam esse arquivo, e a terceira é a que mais surpreende:
| Regra | Porque |
|---|---|
cada construtor espelha um exec_/eval_ e delega aos mesmos auxiliares | a semântica não é reimplementada; divergir faria a linguagem responder duas coisas |
| o que não está na tabela recua para o interpretador | um recurso novo continua funcionando sem tocar aqui — só não fica mais rápido |
| o depurador desliga tudo | ele para em cada linha sombreando execute, e o corpo compilado passaria por fora: um depurador que enxerga metade das instruções é pior que um interpretador lento |
Por que não há bytecode#
A pergunta é justa: quase toda linguagem interpretada compila para bytecode e roda uma VM. A resposta é um número.
| Técnica | Ganho medido | Custo |
|---|---|---|
| interpretador de árvore (o ponto de partida) | 1× | — |
| tabela de despacho | incluída abaixo | pequeno |
| compilação para fechamentos (hoje) | 1,5× a 1,8× | um arquivo de 330 linhas |
| VM de bytecode em Python | ~6,5× (o teto) | reescrever o interpretador inteiro |
| sair do Python | muito mais | a promessa de zero dependência acaba |
O teto de 6,5× é o ponto: uma VM escrita em Python continua sendo Python executando o laço de despacho. O ganho existe e não é transformador, e o custo é reescrever a parte do sistema que mais tem teste e mais tem semântica sutil.
O escopo, e a otimização mais perigosa#
Um laço reaproveita o escopo entre voltas — alocar um por volta é caro. Mas se o corpo captura o escopo (uma ação, um lambda, um blueprint, um thread, um defer), cada volta precisa do seu:
acoes := []
cycle i from 1 to 3:
acoes.append(lambda: i)
out [f() cycle f in acoes] // [1, 2, 3] — e não [3, 3, 3]Sem essa distinção, as três closures veriam o último valor — o clássico que existe em várias linguagens e que aqui não acontece. A varredura olha a árvore inteira, e não só as instruções: um lambda vive dentro de uma expressão.
A pilha, e o salto de cauda#
O teto de quadros é mil, e recursão legítima o atinge: uma travessia de árvore de cinco mil nós não tem nada de infinita. Há duas saídas, e a primeira é automática:
action somar_ate(n, total := 0):
given n is 0:
yield total
yield somar_ate(n - 1, total + n) // retorno INTEIRO: vira salto
out somar_ate(50000)yield f(…) como retorno inteiro não empilha: o quadro é reusado. Testado com 200 mil. Quatro casos são recusados pela análise, antes de rodar — há defer na ação, o yield está dentro de monitor, a recursão é indireta, ou todo yield da ação é cauda (aí ela nunca devolve, e virar laço mudo seria pior que o erro).
Threads, processos e o GIL#
| Ferramenta | Paralelismo real | Para quê |
|---|---|---|
thread: / parallel: | não em CPU | rede, disco, banco, espera |
async / await | não em CPU | entrada e saída sobreposta |
P.map_processos | sim | trabalho de CPU — medido 4,71× em 10 núcleos |
O GIL do Python deixa uma thread por vez executar bytecode. A travessia de processo copia a declaração da ação, e não o fechamento — ver complexidade em paralelo.