10. Geração de código: subrotinas e objetos¶
Este capítulo fecha o compilador Jack → VM: variáveis (usando a tabela de símbolos do capítulo 6), a declaração completa de function/method/constructor (com o prólogo que aloca variáveis locais e, para construtores, memória de objeto), chamadas de subroutine, e o mapeamento de arrays via aritmética de ponteiros. Ao final, o Project 11 do Nand2Tetris estará completo: qualquer programa Jack válido poderá ser compilado para VM.
De categoria para segmento¶
A tabela de símbolos (capítulo 6) guarda a categoria de uma variável (STATIC, FIELD, ARG, VAR); a VM (capítulo 8) trabalha com segmentos (static, this, argument, local). Uma função pura faz essa ponte — e é usada em todo lugar que o compilador precisa gerar push/pop para uma variável:
private Segment kind2Segment(Kind kind) {
return switch (kind) {
case STATIC -> Segment.STATIC;
case FIELD -> Segment.THIS; // campos vivem no segmento `this`
case VAR -> Segment.LOCAL;
case ARG -> Segment.ARG;
};
}
O único mapeamento não óbvio é FIELD → THIS: campos de um objeto são acessados através do segmento this, que aponta (via pointer 0) para o objeto correntemente "ativo" — voltaremos a isso na seção de métodos.
Acessando variáveis em expressões¶
Quando parseTerm encontra um identificador que não é seguido de ( nem . (ou seja, não é uma chamada de subroutine — distinção que revisitamos adiante), ele é uma variável simples: basta resolver na tabela de símbolos e emitir o push correspondente.
case IDENT:
expectPeek(IDENT);
Symbol sym = symbolTable.resolve(currentToken.lexeme);
if (peekTokenIs(LPAREN) || peekTokenIs(DOT)) {
parseSubroutineCall();
} else if (peekTokenIs(LBRACKET)) {
// acesso a array — ver seção dedicada abaixo
} else {
vmWriter.writePush(kind2Segment(sym.kind()), sym.index());
}
break;
Do lado esquerdo de uma atribuição (let x = ...), o mesmo símbolo resolve para um pop em vez de um push — calcule primeiro o valor da direita, desempilhe-o para a variável depois:
void parseLet() {
expectPeek(LET);
expectPeek(IDENT);
Symbol symbol = symbolTable.resolve(currentToken.lexeme);
boolean isArray = false;
if (peekTokenIs(LBRACKET)) {
// ver seção de arrays
isArray = true;
}
expectPeek(EQ);
parseExpression();
if (!isArray) {
vmWriter.writePop(kind2Segment(symbol.kind()), symbol.index());
}
expectPeek(SEMICOLON);
}
Declarando funções¶
O nome de uma função VM é sempre Classe.nomeDaSubroutine — então o Parser precisa lembrar o nome da classe corrente (capturado uma única vez, em parseClass):
O comando function só pode ser emitido depois que todas as variáveis locais já foram registradas na tabela de símbolos (capítulo 6) — porque nLocais é exatamente symbolTable.varCount(Kind.VAR). Por isso a emissão acontece no corpo da subroutine, não na sua declaração:
void parseSubroutineBody(String functionName) {
expectPeek(LBRACE);
while (peekTokenIs(VAR)) parseVarDec();
vmWriter.writeFunction(functionName, symbolTable.varCount(Kind.VAR));
parseStatements();
expectPeek(RBRACE);
}
Com isso, uma função simples já compila de ponta a ponta:
@Test
void testSimpleFunctions() {
var input = """
class Main {
function int soma(int x, int y) { return 30; }
function void main() { var int d; return; }
}
""";
var parser = new Parser(input.getBytes());
parser.parse();
assertEquals("""
function Main.soma 0
push constant 30
return
function Main.main 1
push constant 0
return
""", parser.VMOutput());
}
Construtores: alocando o objeto¶
Um constructor precisa, antes de mais nada, alocar espaço no heap para o novo objeto — e apontar this (via pointer 0) para esse espaço recém-alocado. O tamanho a alocar é exatamente o número de fields da classe, já conhecido pela tabela de símbolos:
if (subroutineType == CONSTRUCTOR) {
vmWriter.writePush(Segment.CONST, symbolTable.varCount(Kind.FIELD));
vmWriter.writeCall("Memory.alloc", 1);
vmWriter.writePop(Segment.POINTER, 0); // this = endereço retornado por Memory.alloc
}
A partir daí, o restante do construtor é só comandos normais — let x = ax; vira push argument 0 seguido de pop this 0, exatamente como qualquer atribuição a um campo. O return this; no final de um construtor Jack é apenas push pointer 0 seguido de return — devolver o endereço do objeto que acabou de ser inicializado.
compila para:
function Point.new 0
push constant 2 // 2 campos: x e y
call Memory.alloc 1
pop pointer 0 // this = endereço alocado
push argument 0
pop this 0 // x = ax
push argument 1
pop this 1 // y = ay
push pointer 0
return // return this
Métodos: recebendo this¶
Um método Jack (p.getX()) é, na VM, uma função comum que recebe o objeto receptor como primeiro argumento. O capítulo 6 já garantiu que, ao compilar um método, "this" foi registrado como arg de índice 0 — falta apenas, no prólogo do método, materializar esse argumento no registrador pointer 0, para que o restante do corpo possa usar this 0, this 1 etc. normalmente:
if (subroutineType == METHOD) {
vmWriter.writePush(Segment.ARG, 0);
vmWriter.writePop(Segment.POINTER, 0); // this = argument 0
}
Depois dessa única linha extra no prólogo, o corpo de um método é indistinguível, em termos de geração de código, do corpo de qualquer outra função.
Chamadas: distinguindo função, método e método de outro objeto¶
A parte mais delicada da geração de código de chamadas é decidir, a partir de um identificador seguido de ( ou de ., qual das três formas se aplica — e a tabela de símbolos é exatamente a ferramenta que resolve essa ambiguidade:
void parseSubroutineCall() {
String ident = currentToken.lexeme;
Symbol symbol = symbolTable.resolve(ident);
int nArgs = 0;
String functionName;
if (peekTokenIs(LPAREN)) {
// método da própria classe: chamado sem qualificador -> this é implícito
expectPeek(LPAREN);
vmWriter.writePush(Segment.POINTER, 0);
nArgs = parseExpressionList() + 1;
expectPeek(RPAREN);
functionName = className + "." + ident;
} else {
expectPeek(DOT);
expectPeek(IDENT);
if (symbol != null) {
// "ident" é uma VARIÁVEL -> é um método de outro objeto
functionName = symbol.type() + "." + currentToken.lexeme;
vmWriter.writePush(kind2Segment(symbol.kind()), symbol.index());
nArgs = 1; // o próprio objeto, como primeiro argumento
} else {
// "ident" não está na tabela -> é o nome de uma CLASSE -> função
functionName = ident + "." + currentToken.lexeme;
}
expectPeek(LPAREN);
nArgs += parseExpressionList();
expectPeek(RPAREN);
}
vmWriter.writeCall(functionName, nArgs);
}
A regra de ouro está no if (symbol != null): se o identificador antes do . resolve para algo na tabela de símbolos, ele é uma variável (logo, variavel.metodo(...) é uma chamada de método sobre outro objeto, e esse objeto precisa ser empilhado como argumento extra); se não resolve, é o nome de uma classe, e Classe.funcao(...) é uma chamada de função comum, sem receptor implícito.
com p1 resolvido como uma variável do tipo Point, compila para:
push local 0 // empilha p1 (o receptor)
push local 1 // empilha p2 (o argumento explícito)
call Point.distance 2
pop local 2
Arrays: aritmética de ponteiros¶
Um array Jack é, para o compilador, apenas um ponteiro para o endereço-base de um bloco contíguo no heap — arr[i] é açúcar sintático para "o valor no endereço arr + i". A VM não tem um comando de "acesso indexado" dedicado; ela oferece endereçamento indireto através do par pointer 1 / segmento that: gravar um endereço em pointer 1 faz that 0 passar a se referir àquela célula de memória.
Uma primeira tentativa ingênua — calcular o endereço, apontar that para lá, e então avaliar o lado direito — quebra em casos como let a[i] = b[j], porque avaliar b[j] (o lado direito) também precisa usar pointer 1/that, sobrescrevendo o endereço que tínhamos acabado de calcular para a[i]. A solução é guardar o valor do lado direito em temp 0 antes de mexer em pointer 1:
// let arr[expr1] = expr2
código VM de arr (push do segmento/índice de arr)
código VM de expr1
add // topo da pilha = endereço de arr[expr1]
código VM de expr2 // calcula o valor a atribuir
pop temp 0 // guarda o valor calculado, ANTES de mexer em pointer 1
pop pointer 1 // agora sim: that passa a apontar para arr[expr1]
push temp 0
pop that 0 // arr[expr1] = valor
// dentro de parseLet, ao ver LBRACKET:
expectPeek(LBRACKET);
vmWriter.writePush(kind2Segment(symbol.kind()), symbol.index());
parseExpression();
vmWriter.writeArithmetic(Command.ADD);
expectPeek(RBRACKET);
isArray = true;
// ... depois de compilar a expressão do lado direito do '=':
if (isArray) {
vmWriter.writePop(Segment.TEMP, 0);
vmWriter.writePop(Segment.POINTER, 1);
vmWriter.writePush(Segment.TEMP, 0);
vmWriter.writePop(Segment.THAT, 0);
}
Ler um elemento de array (dentro de parseTerm, quando o identificador é seguido de [) é mais simples, porque não há um segundo acesso a that em conflito — apenas calcule o endereço, aponte that para lá, e empilhe o valor:
vmWriter.writePush(kind2Segment(sym.kind()), sym.index());
parseExpression();
vmWriter.writeArithmetic(Command.ADD);
expectPeek(RBRACKET);
vmWriter.writePop(Segment.POINTER, 1);
vmWriter.writePush(Segment.THAT, 0);
Como exercício de verificação, vale compilar mentalmente (ou de fato, testando) uma expressão deliberadamente hostil como let a[a[i]] = a[b[a[b[j]]]] — se o temp 0 intermediário estiver no lugar certo, mesmo esse emaranhado de acessos aninhados produz código correto.
Fechando o Project 11¶
Com variáveis, funções, construtores, métodos, chamadas e arrays todos gerando código VM, o compilador Jack → VM está completo — qualquer arquivo .jack válido, de qualquer complexidade, agora compila para um .vm executável no VM Emulator do Nand2Tetris. É um bom momento para rodar contra os programas de exemplo maiores do Project 11 (Seven, ConvertToBin, Square, Pong, Average) e comparar a saída — quando não bate byte a byte com a referência oficial, normalmente ainda é possível validar comportamentalmente, executando ambos no VM Emulator e comparando o resultado observável.
O que vem a seguir¶
O compilador está pronto, mas ele só produz código VM — que ainda não roda em hardware nenhum. A Unidade 3 fecha essa lacuna: o capítulo 11 (ainda nesta unidade) começa o VM Translator, traduzindo aritmética e acesso à memória para Assembly Hack; o capítulo 12 completa o protocolo de função (call/return, stack frames); e os capítulos 13–15 tratam da geração de código alvo final e do Assembler.