Pular para o conteúdo

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.

text
arquivo.df → tokenize() → parse() → [check_program()] → run(ast)
             lexer.py    parser.py  typechecker.py      interpreter.py

As cinco etapas#

EtapaArquivoO que entra e o que sai
léxicolexer.pytexto → tokens, com INDENT/DEDENT e interpolação
sintaxeparser.pytokens → árvore, por descida recursiva
análisetypechecker.pyárvore → diagnósticos, sem executar nada
compilaçãocompilador.pyárvore → fechamentos Python, uma vez
execuçãointerpreter.pyfechamentos → 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:

dataforge
given x > 5:
    out "grande"
out "fim"
text
GIVEN  IDENT(x)  GT  INT(5)  COLON  NEWLINE
INDENT  OUT  STRING  NEWLINE
DEDENT  OUT  STRING  NEWLINE  EOF

Tab é 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:

  1. Tabela por classe — o nome vira uma entrada de dicionário resolvida uma vez.
  2. 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ó.
dataforge
// 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 += i

Medido: 1,5× a 1,8×, conforme a carga. Três regras governam esse arquivo, e a terceira é a que mais surpreende:

RegraPorque
cada construtor espelha um exec_/eval_ e delega aos mesmos auxiliaresa semântica não é reimplementada; divergir faria a linguagem responder duas coisas
o que não está na tabela recua para o interpretadorum recurso novo continua funcionando sem tocar aqui — só não fica mais rápido
o depurador desliga tudoele 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écnicaGanho medidoCusto
interpretador de árvore (o ponto de partida)
tabela de despachoincluída abaixopequeno
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 Pythonmuito maisa 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:

dataforge
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:

dataforge
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#

FerramentaParalelismo realPara quê
thread: / parallel:não em CPUrede, disco, banco, espera
async / awaitnão em CPUentrada e saída sobreposta
P.map_processossimtrabalho 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.

Por onde seguir#