Don’t be a boilerplate programmer. Instead, build tools for users and other programmers. Take historical note of textile and steel industries: do you want to build machines and tools, or do you want to operate those machines?
If you don’t take compilers then you run the risk of forever being on the programmer B-list: the kind of eager young architect who becomes a saturnine old architect who spends a career building large systems and being damned proud of it.
Tokenization involves turning a textual program into strings with a set meaning.
[Keyword("int"), Identifier("main"), LParen, Keyword("void"),
RParen, LBrace, Keyword("return"), Int(5), Semicolon, RBrace]You’ll want to keep spans for error handling, annotating each token with its location in the source file in case of errors.
Parsing turns these tokens into an Abstract Syntax Tree (AST) which is a form of the program that can be traversed in order to generate output.
Say we have a language of this form, which supports addition, subtraction, multiplication, division, parentheses, and order of operations:
We would have a grammar like this:
Expr -> Term { ("+"|"-") Term }
Term -> Factor { ("*"|"/") Factor }
Factor -> Number | "(" Expr ")"
One approach is recursive descent which is a natural translation of this grammar into code.
pub fn parse_expr(&mut self) -> Result<f64, String> {
let mut value = self.parse_term()?;
while let Token::Plus | Token::Minus = self.current_token {
let op = self.current_token.clone();
self.consume(op.clone())?;
let right = self.parse_term()?;
value = match op {
Token::Plus => value + right,
Token::Minus => value - right,
_ => unreachable!(),
};
}
Ok(value)
}fn parse_term(&mut self) -> Result<f64, String> {
let mut value = self.parse_factor()?;
while let Token::Star | Token::Slash = self.current_token {
let op = self.current_token.clone();
self.consume(op.clone())?;
let right = self.parse_factor()?;
value = match op {
Token::Star => value * right,
Token::Slash => value / right,
_ => unreachable!(),
};
}
Ok(value)
}fn parse_factor(&mut self) -> Result<f64, String> {
match &self.current_token {
Token::Number(n) => {
self.consume(self.current_token.clone())?;
Ok(*n)
}
Token::LParen => {
self.consume(Token::LParen)?;
let expr_val = self.parse_expr()?;
self.consume(Token::RParen)?;
Ok(expr_val)
}
_ => Err(format!("Unexpected token: {:?}", self.current_token)),
}
}Let’s say we have a language that has strings and numbers, where you can add numbers or concat strings:
We want to check for the last case and throw an error here:
We may want to turn our AST into a form that’s more amenable to optimization. One form is Three Address Codes (TAC). LLVM uses Single Static Assignment (SSA) but we’ll stick to TAC.
This can be turned into a TAC like so: (each operation has a target, and at most two operands):
a = 1
b = 2
c = 3
d = 4
_t0 = b + c;
_t1 = _t0 + d;
a = _t1;
_t2 = a * a;
_t3 = b * b;
b = _t1 + _t2;
We can then optimize this program:
There is one unnecessary move:
_t0 = b + c;
_t1 = _t0 + d;
a = _t1;
_t0 = b + c;
a = _t0 + d;
We can do some peephole optimizations over and over again till we get this result. Note that since we don’t use a or b, technically we can delete the program itself.
a = 9
b = 5
One (there are many interesting optimizations, take a look at Hacker’s Delight for more) optimization is called Strength Reduction: take a statement and replace it with something that’s cheaper to calculate but gives you the same result.
One example:
An example of looping through an array to print (1, 2, 3) on their own lines:
Which lowered looks like this. Note there’s a multiply to iterate the array:
array_ptr = $0800 # some random address
size_of_item = 4 # ints are 4 bytes
load i = 0
for the next three times:
tmp = load array[i]
print tmp
i += 1
array_ptr += size_of_item * i
We said the multiply is expensive. Let’s replace it with addition:
array_ptr = $0800 # some random address
size_of_item = 4 # ints are 4 bytes
load i = 0
offset = 0
for the next three times:
tmp = load array[i]
print tmp
i += 1
offset += size_of_item
array_ptr += offset
Finally, you lower the IR or AST into its target language. This is a pretty straightforward translation, keeping in mind that some things may not be supported in the target language, so those require extra handling.
How do you test a compiler?
There are infinitely many programs for any turing complete language, thus the set of programs a compiler can accept is infinitely many. Writing tests one by one (a whole program) would take you infinite time.
One way is by fuzzing: generating random programs that fit the semantics of the input language and making sure they’re correctly handled in the output language. You can also provide incorrect programs and make sure they error out correctly.