Add more to part 12 of compiler series
This commit is contained in:
parent
2d9da2899f
commit
eda9bbb191
|
@ -38,7 +38,7 @@ The part that doesn't translate well is the whole deal with patterns in function
|
||||||
It's not that these things are <em>impossible</em> to translate; it's just that translating them may be worthy of a post in and of itself, and would only serve to bloat and complicate this part. What can be implemented with pattern arguments can just as well be implemented using regular case expressions; I dare say most "big" functional languages actually just convert from the former to the latter as part of the compillation process.
|
It's not that these things are <em>impossible</em> to translate; it's just that translating them may be worthy of a post in and of itself, and would only serve to bloat and complicate this part. What can be implemented with pattern arguments can just as well be implemented using regular case expressions; I dare say most "big" functional languages actually just convert from the former to the latter as part of the compillation process.
|
||||||
{{< /sidenote >}} illustrates another important principle:
|
{{< /sidenote >}} illustrates another important principle:
|
||||||
|
|
||||||
```Haskell
|
```Haskell {linenos=table}
|
||||||
let
|
let
|
||||||
safeTail [] = Nothing
|
safeTail [] = Nothing
|
||||||
safeTail [x] = Just x
|
safeTail [x] = Just x
|
||||||
|
@ -69,4 +69,37 @@ Things are more complicated now that `let/in` expressions are able to introduce
|
||||||
|
|
||||||
In the above image, some of the original nodes in our graph now contain other, smaller graphs. Those subgraphs are the graphs created by function declarations in `let/in` expressions. Just like our top-level nodes, the nodes of these smaller graphs can depend on other nodes, and even form cycles. Within each subgraph, we will have to perform the same kind of cycle detection, resulting in something like this:
|
In the above image, some of the original nodes in our graph now contain other, smaller graphs. Those subgraphs are the graphs created by function declarations in `let/in` expressions. Just like our top-level nodes, the nodes of these smaller graphs can depend on other nodes, and even form cycles. Within each subgraph, we will have to perform the same kind of cycle detection, resulting in something like this:
|
||||||
|
|
||||||
{{< figure src="fig_subgraphs_colored_all.png" caption="Augmented dependency graph with mutually recursive groups highlighted." >}}
|
{{< figure src="fig_subgraphs_colored_all.png" caption="Augmented dependency graph with mutually recursive groups highlighted." >}}
|
||||||
|
|
||||||
|
When typechecking a function, we must be ready to perform dependency analysis at any point. What's more is that the free variable analysis we used to perform must now be extended to differentiate between free variables that refer to "nearby" definitions (i.e. within the same `let/in` expression), and "far away" definitions (i.e. outside of the `let/in` expression). And speaking of free variables...
|
||||||
|
|
||||||
|
What do we do about variables that are captured by a local definition? Consider the following snippet:
|
||||||
|
|
||||||
|
```Haskell {linenos=table}
|
||||||
|
addToAll n xs = map addSingle xs
|
||||||
|
where
|
||||||
|
addSingle x = n + x
|
||||||
|
```
|
||||||
|
|
||||||
|
In the code above, the variable `n`, bound on line 1, is used by `addSingle` on line 3. When a function refers to variables bound outside of itself (as `addSingle` does), it is said to be _capturing_ these variables, and the function is called a _closure_. Why does this matter? On the machine level, functions are represented as sequences of instructions, and there's a finite number of them (as there is finite space on the machine). But there is an infinite number of `addSingle` functions! When we write `addToAll 5 [1,2,3]`, `addSingle` becomes `5+x`. When, on the other hand, we write `addToAll 6 [1,2,3]`, `addSingle` becomes `6+x`. There are certain ways to work around this - we could, for instance, dynamically create machine code in memory, and then execute it (this is called [just-in-time compilation](https://en.wikipedia.org/wiki/Just-in-time_compilation)). This would end up with a collections of runtime-defined functions that can be represented as follows:
|
||||||
|
```Haskell {linenos=table}
|
||||||
|
-- Version of addSingle when n = 5
|
||||||
|
addSingle5 x = 5 + x
|
||||||
|
|
||||||
|
-- Version of addSingle when n = 6
|
||||||
|
addSingle6 x = 6 + x
|
||||||
|
|
||||||
|
-- ... and so on ...
|
||||||
|
```
|
||||||
|
But now, we end up creating several functions with almost identical bodies, with the exception of the free variables themselves. Wouldn't it be better to perform the well-known strategy of reducing code duplication by factoring out parameters, and leaving only instance of the repeated code? We would end up with:
|
||||||
|
```Haskell {linenos=table}
|
||||||
|
addToAll n xs = map (addSingle n) xs
|
||||||
|
addSingle n x = n + x
|
||||||
|
```
|
||||||
|
Observe that we no longer have the "infinite" number of functions - the infinitude of possible behaviors is created via currying. Also note that `addSingle`
|
||||||
|
{{< sidenote "right" "global-note" "is now declared at the global scope," >}}
|
||||||
|
Wait a moment, didn't we just talk about nested polymorphic definitions, and how they change our typechecking model? If we transform our program into a bunch of global definitions, we don't need to make adjustments to our typechecking. <br><br>
|
||||||
|
This is true, but why should we perform transformations on a malformed program? Typechecking before pulling functions to the global scope will help us save the work, and breaking down one dependency-searching problem (which is \(O(n^3)\) thanks to Warshall's) into smaller, independent problems may even lead to better performance. Furthermore, typechecking before program transformations will help us come up with more helpful error messages.
|
||||||
|
{{< /sidenote >}} and can be transformed into a sequence of instructions just like any other global function. It has been pulled from its `where` (which, by the way, is pretty much equivalent to a `let/in`) to the top level.
|
||||||
|
|
||||||
|
This technique of replacing captured variables with arguments, and pulling closures into the global scope to aid compilation, is called [Lambda Lifting](https://en.wikipedia.org/wiki/Lambda_lifting). Its name is no coincidence - lambda functions need to undergo the same kind of transformation as our nested definitions (unlike nested definitions, though, lambda functions need to be named). This is why they are included in this post together with `let/in`!
|
Loading…
Reference in New Issue
Block a user