9. Geração de código: expressões e comandos¶
Chegou a hora de evoluir o CompilationEngine — que na Unidade 1 apenas reconhecia a estrutura de um programa Jack, emitindo uma árvore XML — para efetivamente gerar código VM. A boa notícia é que a maior parte do trabalho já está feita: a gramática, o parser recursivo descendente e a tabela de símbolos já existem. Falta "só" substituir printNonTerminal por chamadas que emitem comandos VM reais, nos pontos certos — exatamente a técnica de tradução dirigida por sintaxe formalizada no capítulo 1.
O VMWriter¶
Assim como tivemos um Scanner dedicado a produzir tokens, vamos ter uma classe dedicada a produzir código VM: o VMWriter. Sua única responsabilidade é formatar comandos — ele não sabe nada sobre a gramática de Jack, apenas escreve o que lhe é pedido:
public class VMWriter {
public enum Segment { CONST, ARG, LOCAL, STATIC, THIS, THAT, POINTER, TEMP }
public enum Command { ADD, SUB, NEG, EQ, GT, LT, AND, OR, NOT }
private StringBuilder output = new StringBuilder();
public void writePush(Segment seg, int index) {
output.append("push %s %d\n".formatted(seg.name().toLowerCase(), index));
}
public void writePop(Segment seg, int index) {
output.append("pop %s %d\n".formatted(seg.name().toLowerCase(), index));
}
public void writeArithmetic(Command cmd) {
output.append(cmd.name().toLowerCase() + "\n");
}
public void writeLabel(String label) { output.append("label " + label + "\n"); }
public void writeGoto(String label) { output.append("goto " + label + "\n"); }
public void writeIf(String label) { output.append("if-goto " + label + "\n"); }
public void writeCall(String name, int nArgs) {
output.append("call %s %d\n".formatted(name, nArgs));
}
public void writeFunction(String name, int nLocals) {
output.append("function %s %d\n".formatted(name, nLocals));
}
public void writeReturn() { output.append("return\n"); }
public String output() { return output.toString(); }
}
O Parser ganha uma instância de VMWriter (além do SymbolTable do capítulo 6), e um método VMOutput() espelhando o XMLOutput() que já existia. A partir daqui, cada seção da gramática de expressões e comandos ganha, além do reconhecimento sintático, a emissão de código correspondente — construída e testada incrementalmente, como sempre.
Literais¶
Comecemos pelo caso mais simples: um term que é uma constante inteira já reconhecida vira, imediatamente, um push constant:
case NUMBER:
expectPeek(NUMBER);
vmWriter.writePush(Segment.CONST, Integer.parseInt(currentToken.lexeme));
break;
@Test
void testInt() {
var parser = new Parser("10".getBytes());
parser.parseExpression();
assertEquals("push constant 10\n", parser.VMOutput());
}
Os literais true/false/null/this seguem a mesma receita, mas cada um mapeia para uma sequência VM diferente — vale memorizar essas quatro traduções, porque vão aparecer o tempo todo:
| Literal Jack | Código VM | Por quê |
|---|---|---|
false |
push constant 0 |
falso é 0 |
null |
push constant 0 |
null também é representado como 0 |
true |
push constant 0 seguido de not |
não existe literal -1; not 0 produz -1 (todos os bits em 1) |
this |
push pointer 0 |
this é sempre o conteúdo do registrador pointer 0 (capítulo 8) |
case FALSE: case NULL: case TRUE:
expectPeek(FALSE, NULL, TRUE);
vmWriter.writePush(Segment.CONST, 0);
if (currentToken.type == TRUE) vmWriter.writeArithmetic(Command.NOT);
break;
case THIS:
expectPeek(THIS);
vmWriter.writePush(Segment.POINTER, 0);
break;
Uma string literal exige mais trabalho, porque a VM não tem um tipo string nativo — ela é construída, caractere a caractere, através da classe String do sistema operacional Jack:
case STRING:
expectPeek(STRING);
String s = currentToken.lexeme;
vmWriter.writePush(Segment.CONST, s.length());
vmWriter.writeCall("String.new", 1);
for (int i = 0; i < s.length(); i++) {
vmWriter.writePush(Segment.CONST, s.charAt(i));
vmWriter.writeCall("String.appendChar", 2);
}
break;
Isso já revela um padrão que vai se repetir: uma construção de alto nível (aqui, uma string literal) muitas vezes não vira um único comando VM, mas uma sequência de push/call que invoca a biblioteca padrão.
Operadores binários e unários¶
expression → term (op term)* (capítulo 3/5) já era reconhecida; agora, a cada op reconhecido, emitimos o comando correspondente depois de compilar ambos os termos — a ordem pós-fixa que a VM exige:
void parseExpression() {
parseTerm();
while (isOperator(peekToken.lexeme)) {
TokenType op = peekToken.type;
expectPeek(op);
parseTerm();
compileOperator(op);
}
}
void compileOperator(TokenType op) {
switch (op) {
case ASTERISK -> vmWriter.writeCall("Math.multiply", 2);
case SLASH -> vmWriter.writeCall("Math.divide", 2);
case PLUS -> vmWriter.writeArithmetic(Command.ADD);
case MINUS -> vmWriter.writeArithmetic(Command.SUB);
case LT -> vmWriter.writeArithmetic(Command.LT);
case GT -> vmWriter.writeArithmetic(Command.GT);
case EQ -> vmWriter.writeArithmetic(Command.EQ);
case AND -> vmWriter.writeArithmetic(Command.AND);
case OR -> vmWriter.writeArithmetic(Command.OR);
}
}
* e / são o único caso especial: como a VM não tem comandos nativos para eles (capítulo 8), viram chamadas a Math.multiply/Math.divide — o compilador "esconde" essa diferença do programador Jack, que escreve a * b sem saber que isso é, por baixo, uma chamada de função.
Operadores unários (-x, ~x) seguem o padrão inverso de um binário: compilam o operando primeiro, emitem a operação depois — mas com apenas um operando na pilha:
case MINUS: case NOT:
TokenType unop = currentToken.type; // já consumido via expectPeek antes de chamar parseTerm
parseTerm();
vmWriter.writeArithmetic(unop == MINUS ? Command.NEG : Command.NOT);
break;
O comando return¶
void parseReturn() {
expectPeek(RETURN);
if (!peekTokenIs(SEMICOLON)) {
parseExpression();
} else {
vmWriter.writePush(Segment.CONST, 0); // void: empilha um valor "nulo" por convenção
}
expectPeek(SEMICOLON);
vmWriter.writeReturn();
}
Isso implementa diretamente a convenção do capítulo 8: toda subroutine, void ou não, precisa deixar exatamente um valor na pilha antes do return.
Controle de fluxo: if e while¶
Linguagens de alto nível têm estruturas de controle ricas; a VM só tem goto e if-goto (capítulo 8). Cabe ao compilador fazer esse mapeamento — e o ponto delicado é garantir que os rótulos sejam únicos dentro de cada subroutine (dois ifs na mesma função não podem gerar o mesmo IF_TRUE0). A solução é um contador de instância, zerado a cada parseSubroutineDec:
if/else mapeia para o seguinte esqueleto (o algoritmo aqui é o mesmo usado pelo compilador de referência do Nand2Tetris — não o único possível, mas o que facilita comparar sua saída com a oficial):
código VM da condição
if-goto IF_TRUE0
goto IF_FALSE0
label IF_TRUE0
código VM do bloco then
goto IF_END0 // só existe se houver else
label IF_FALSE0
código VM do bloco else // só existe se houver else
label IF_END0 // só existe se houver else
void parseIf() {
String labelTrue = "IF_TRUE" + ifLabelNum;
String labelFalse = "IF_FALSE" + ifLabelNum;
String labelEnd = "IF_END" + ifLabelNum;
ifLabelNum++;
expectPeek(IF); expectPeek(LPAREN);
parseExpression();
expectPeek(RPAREN);
vmWriter.writeIf(labelTrue);
vmWriter.writeGoto(labelFalse);
vmWriter.writeLabel(labelTrue);
expectPeek(LBRACE); parseStatements(); expectPeek(RBRACE);
if (peekTokenIs(ELSE)) vmWriter.writeGoto(labelEnd);
vmWriter.writeLabel(labelFalse);
if (peekTokenIs(ELSE)) {
expectPeek(ELSE);
expectPeek(LBRACE); parseStatements(); expectPeek(RBRACE);
vmWriter.writeLabel(labelEnd);
}
}
while é mais simples, porque não há ramificação — só um teste no início do laço:
label WHILE_EXP0
código VM da condição
not
if-goto WHILE_END0
código VM do corpo
goto WHILE_EXP0
label WHILE_END0
O not antes do if-goto é o detalhe que vale internalizar: a VM só tem "salte se verdadeiro" — para "saia do laço quando a condição for falsa", invertemos a condição antes de testar.
void parseWhile() {
String labelExp = "WHILE_EXP" + whileLabelNum;
String labelEnd = "WHILE_END" + whileLabelNum;
whileLabelNum++;
vmWriter.writeLabel(labelExp);
expectPeek(WHILE); expectPeek(LPAREN);
parseExpression();
vmWriter.writeArithmetic(Command.NOT);
vmWriter.writeIf(labelEnd);
expectPeek(RPAREN);
expectPeek(LBRACE); parseStatements(); expectPeek(RBRACE);
vmWriter.writeGoto(labelExp);
vmWriter.writeLabel(labelEnd);
}
O que levamos daqui¶
Neste capítulo, expressões e a maior parte dos comandos de Jack já geram código VM real — sempre seguindo o mesmo princípio: reconhecer a estrutura (o parser da Unidade 1 já faz isso) e, no ponto certo da produção, emitir o comando VM correspondente, sem nunca materializar uma árvore sintática intermediária. O que falta — variáveis (usando a tabela de símbolos do capítulo 6 para resolver push/pop corretos), subroutines completas (com prólogo de função e alocação de objetos) e arrays — é o assunto do capítulo 10, que fecha o Project 11 por completo.