Skip to content

Commit b49e4cf

Browse files
authored
Book: compiler design chapter (#42)
1 parent 82c6778 commit b49e4cf

File tree

3 files changed

+285
-2
lines changed

3 files changed

+285
-2
lines changed

booksrc/SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
- [Bytecode](./chapter-interp-bytecode.md)
2020
- [Dicts](./chapter-interp-dicts.md)
2121
- [Virtual Machine: Design](./chapter-interp-vm-design.md)
22-
- [TODO - Compiler: Design](./404.md)
22+
- [Compiler: Design](./chapter-interp-compiler-design.md)
2323
- [TODO - Virtual Machine: Implementation](./404.md)
2424
- [TODO - Compiler: Implementation](./404.md)
2525
- [Garbage collection](./404.md)
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# Compiler: Design
2+
3+
Drawing from the [VM design](./chapter-interp-vm-design.md), the compiler must
4+
support the following language constructs:
5+
6+
* function definitions
7+
* anonymous functions
8+
* function calls
9+
* lexical scoping
10+
* closures
11+
* local variables
12+
* global variables
13+
* expressions
14+
15+
This is a minimally critical set of features that any further language
16+
constructs can be built on while ensuring that our compiler remains easy to
17+
understand for the purposes of this book.
18+
19+
[Our parser, recall](./chapter-interp-parsing.md), reads in s-expression syntax
20+
and produces a nested `Pair` and `Symbol` based abstract syntax tree. Adding
21+
other types - integers, strings, arrays etc - is mostly a matter of expanding
22+
the parser. The compiler as described here, being for a dynamically typed
23+
language, will support them without refactoring.
24+
25+
## Eval/apply
26+
27+
Our compiler design is based on the _eval/apply_ pattern.
28+
29+
In this pattern we recursively descend into the `Pair` AST, calling _eval_ on
30+
the root node of the expression to be compiled.
31+
32+
_Eval_ is, of course, short for "evaluate" - we want to evaluate the given
33+
expression. In the case of a compiler, we don't want the result yet, rather
34+
the sequence of instructions that will generate the result.
35+
36+
More concretely, _eval_ looks at the node in the AST it is given and if it
37+
resolves to fetching a value for a variable, it generates that instruction;
38+
otherwise if it is a compound expression, the arguments are evaluated and then
39+
the function and arguments are passed to _apply_, which generates appropriate
40+
function call instructions.
41+
42+
### Implementing Eval
43+
44+
_Eval_ looks at the given node and attempts to generate an instruction for it
45+
that would resolve the node to a value - that is, evaluate it.
46+
47+
#### Symbols
48+
49+
If the node is a special symbol, such as `nil` or `true`, then it is treated as
50+
a literal and an instruction is generated to load that literal symbol into the
51+
next available register.
52+
53+
Otherwise if the node is any other symbol, it is assumed to be bound to a value
54+
(it must be a variable) and an instruction is generated for fetching the value
55+
into a register.
56+
57+
Variables come in three kinds: local, nonlocal or global.
58+
59+
**Local**: the symbol has been declared earlier in the expression (either it is
60+
a function parameter or it was declared using `let`) and the compiler already
61+
has a record of it. The symbol is already associated with a local register
62+
index and a simple register copy instruction is generated.
63+
64+
**Nonlocal**: the symbol has been bound in a parent nesting function. Again,
65+
the compiler already has a record of the declaration, which register is
66+
associated with the symbol and which relative call frame will contain that
67+
register. An upvalue lookup instruction is generated.
68+
69+
**Global**: if the symbol isn't found as a local binding or a nonlocal binding,
70+
it is assumed to be a global, and a late-binding global lookup instruction is
71+
generated. In the event the programmer has misspelled a variable name, this is
72+
possibly the instruction that will be generated and the programmer will see an
73+
unknown-variable error at runtime.
74+
75+
#### Expressions and function calls
76+
77+
When _eval_ is passed a `Pair`, this represents the beginning of an expression,
78+
a function call. A composition of things.
79+
80+
In s-expression syntax, all expressions and function calls looks like
81+
`(function_name arg1 arg2)`. That is parsed into a `Pair` tree, which takes
82+
the form:
83+
84+
```
85+
Pair(
86+
Symbol(function_name),
87+
Pair(
88+
Symbol(arg1),
89+
Pair(
90+
Symbol(arg2),
91+
nil
92+
)
93+
)
94+
)
95+
```
96+
97+
It is _apply_'s job to handle this case, so _eval_ extracts the first and
98+
second values from the outermost `Pair` and passes them into apply. In more
99+
general terms, _eval_ calls _apply_ with the function name and the argument
100+
list and leaves the rest up to _apply_.
101+
102+
### Implementing Apply
103+
104+
_Apply_ takes a function name and a list of arguments. First it recurses into
105+
_eval_ for each argument expression, then generates instructions to call the
106+
function with the argument results.
107+
108+
#### Calling functions
109+
110+
Functions are either built into to the language and VM or are
111+
library/user-defined functions composed of other functions.
112+
113+
In every case, the simplified pattern for function calls is:
114+
115+
* allocate a register to write the return value into
116+
* _eval_ each of the arguments in sequence, allocating their resulting values
117+
into consequent registers
118+
* compile the function call opcode, giving it the number of argument registers
119+
it should expect
120+
121+
Compiling a call to a builtin function might translate directly to a dedicated
122+
bytecode operation. For example, querying whether a value is `nil` with builtin
123+
function `nil?` compiles 1:1 to a bytecode operation that directly represents
124+
that query.
125+
126+
Compiling a call to a user defined function is a more involved. In it's more
127+
general form, supporting first class functions and closures, a function call
128+
requires two additional pointers to be placed in registers. The complete
129+
function call register allocation looks like this:
130+
131+
| Register | Use |
132+
|----------|-----|
133+
| 0 | reserved for return value |
134+
| 1 | reserved for closure environment pointer |
135+
| 2 | first argument |
136+
| 3 | second argument |
137+
| ... | |
138+
| n | function pointer |
139+
140+
If a closure is called, the closure object itself contains a pointer to it's
141+
environment and the function to call and those pointers can be copied over to
142+
registers. Otherwise, the closure environment pointer will be a `nil` pointer.
143+
144+
The VM, when entering a new function, will represent the return value register
145+
always as the zeroth register.
146+
147+
When the function call returns, all registers except the return value are
148+
discarded.
149+
150+
#### Compiling functions
151+
152+
Let's look at a simple function definition:
153+
154+
```
155+
(def is_true (x)
156+
(is? x true))
157+
```
158+
159+
This function has a name `is_true`, takes one argument `x` and evaluates one
160+
expression `(is? x true)`.
161+
162+
The same function may be written without a name:
163+
164+
```
165+
(lambda (x) (is? x true))
166+
```
167+
168+
Compiling a function requires a few inputs:
169+
170+
* an optional reference to a parent nesting function
171+
* an optional function name
172+
* a list of argument names
173+
* a list of expressions that will compute the return value
174+
175+
The desired output is a data structure that combines:
176+
177+
* the optional function name
178+
* the argument names
179+
* the compiled bytecode
180+
181+
First, a scope structure is established. A scope is a lexical block in which
182+
variables are bound and unbound. In the compiler, this structure is simply a
183+
mapping of variable name to the register number that contains the value.
184+
185+
The first variables to be bound in the function's scope are the argument names.
186+
The compiler, given the list of argument names to the function and the order in
187+
which the arguments are given, associates each argument name with the register
188+
number that will contain it's value. As we saw above, these are predictably and
189+
reliably registers 2 and upward, one for each argument.
190+
191+
A scope may have a parent scope if the function is defined within another
192+
function. This is how nonlocal variable references will be looked up. We will
193+
go further into that when we discuss closures.
194+
195+
The second step is to _eval_ each expression in the function, assigning the
196+
result to register 0, the preallocated return value register. The result of
197+
compiling each expression via _eval_ is bytecode.
198+
199+
Thirdly and finally, a function object is instantiated, given it's name, the
200+
argument names and the bytecode.
201+
202+
#### Compiling closures
203+
204+
During compilation of the expressions within a function, if any of those
205+
expressions reference nonlocal variables (that is, variables not declared
206+
within the scope of the function) then the function object needs additional
207+
data to describe how to access those nonlocal variables at runtime.
208+
209+
In the below example, the anonymous inner function references the parameter
210+
`n` to the outer function, `n`. When the inner function is returned, the value
211+
of `n` must be carried with it even after the stack scope of the outer function
212+
is popped and later overwritten with values for other functions.
213+
214+
```
215+
(def make_adder (n)
216+
(lambda (x) (+ x n))
217+
)
218+
```
219+
220+
_Eval_, when presented with a symbol to evaluate that has not been declared in
221+
the function scope, searches outer scopes next. If a binding is found in an
222+
outer scope, a nonlocal reference is added to the function's _local_ scope
223+
that points to the outer scope and a `GetUpvalue` instruction is compiled.
224+
225+
This nonlocal reference is a combination of two values: a count of stack
226+
frames to skip over to find the outer scope variable and the register offset in
227+
that stack frame.
228+
229+
Non-local references are added to the function object that is returned by the
230+
function compiler. The VM will use these to identify the absolute location on
231+
the stack where a nonlocal variable should be read from and create upvalue
232+
objects at runtime when a variable is closed over.
233+
234+
#### Compiling let
235+
236+
Let is the declaration of variables and assigning values: the binding of
237+
values, or the results of expressions, to symbols. Secondly, it provides
238+
space to evaluate expressions that incorporate those variables.
239+
240+
Here we bind the result of `(make_adder 3)` - a function - to the symbol
241+
`add_3` and then call `add_3` with argument `4`.
242+
243+
```
244+
(let ((add_3 (make_adder 3)))
245+
(add_3 4))
246+
```
247+
248+
The result of the entire `let` expression should be `7`.
249+
250+
Compiling `let` simply introduces additional scopes within a function scope.
251+
That is, instead of a function containing a single scope for all it's
252+
variables, scopes are nested. A stack of scopes is needed, with the parameters
253+
occupying the outermost scope.
254+
255+
First a new scope is pushed on to the scope stack and each symbol being bound
256+
is added to the new scope.
257+
258+
To generate code, a result register is reserved and a register for each binding
259+
is reserved.
260+
261+
Finally, each expression is evaluated and the scope is popped, removing the
262+
bindings from view.
263+
264+
## Register allocation
265+
266+
A function call may make use of no more than 256 registers. Recall from earlier
267+
that the 0th register is reserved for the function return value and subsequent
268+
registers are reserved for the function arguments.
269+
270+
Beyond these initial registers the compiler uses a simple strategy in register
271+
allocation: if a variable (a parameter or a `let` binding) is declared, it is
272+
allocated a register based on a stack discipline. Thus, variables are
273+
essentially pushed and popped off the register stack as they come into and out
274+
of scope.
275+
276+
This strategy primarily ensures code simplicity - there is no register
277+
allocation optimization.
278+
279+
## C'est tout!
280+
281+
That covers the VM and compiler design at an overview level. We've glossed over
282+
a lot of detail but the next chapters will expose the implementation detail.
283+
Get ready!

booksrc/chapter-interp-vm-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ to another value. The VM will, of course, have an abstraction over the internal
7575

7676
## Closures
7777

78-
In the classic Upvalues implementation from Lua 5, followed also by [Crafting
78+
In the classic upvalues implementation from Lua 5, followed also by [Crafting
7979
Interpreters][2], a linked list of upvalues is used to map stack locations to
8080
shared variables.
8181

0 commit comments

Comments
 (0)