Want to Write a Compiler? Read These Two Papers (2008)
prog21.dadgum.com
prog21.dadgum.com
The compiler is written in Oberon however, which is also the source language (actually the source language is Oberon-0, a subset) but, Oberon is super simple and can be learned on the go.
[1] https://www.inf.ethz.ch/personal/wirth/CompilerConstruction/...
Also accessible from that site.
I learned compilers from Al Aho, and believe me, the class was no better in person. Mostly in-person waxing poetic about his time at Bell Labs...
In fairness, though -- or more an admission of how wrong we can be about things like this -- I subscribed to the "parsing is boring and solved" belief until Bryan Ford's Packrat Parsing work made me realize that it had just gotten stuck at a traffic light for a few decades.
Because parsing is a field of little active research interest where most of the work happened more than 20 years ago, there are a lot of techniques from the 70s, 80s, and 90s that are relatively unknown.
There are exceptions, but relatively few production compilers uses anything else, and most of the innovations in parsing provides relatively little value in this space because they tend to be focused on better expressing complex languages rather than provide improved ways of expressing / capturing errors, and it's the latter that would provide most benefit for deterministic languages.
With less risk of immodesty you may also find http://arxiv.org/pdf/1207.0443.pdf?ref=driverlayer.com/web interesting.
There's probably more recent work than these two papers, but I'm a little out of date when it comes to the PEG world.
PEG is a model for recognizers, distinct from traditional grammers whose theoretical model is usually based on generating strings rather than recognising them. (Yes this is a bit backwards.) The most distinctive feature of PEGs is the ordered choice operator - traditional context-free grammars and regexes use unordered choice, but ordered choice is a closer model of how recursive descent parsers work in practice. Ordered choice naturally leads to implementations that have less backtracking than common regex matchers, e.g. Lpeg.
Packrat parsers are based on a clever non-backtracking PEG matching algorithm. They spend O(n) memory to get O(n) parsing time, so they are reliably fast but not good for long inputs. They are also effective in a pure, lazy setting like Haskell.
Lpeg is a PEG parser but not a packrat parser.
Full context-free grammar are supported by "generalised parsing". The older stuff is GLR by Tomita, Early parsing, the CYK algorithm. The newer stuff based on Tomita is the particular rabbit hole I stuck with for a while. I read about SGLR which eliminates lexers, GLL which is GLR but based on the LL algorithm. The people who do GLL research also did improvements on SGLR with improved speed on right-nulled grammars. Then there is the SGLR improvements with disambiguation filters, automatic derivation of error recovery with island grammars, etc. The disambiguation filters include a kind of negation, making the current implementation of SGLR for SDF capable to parsing Boolean Grammars, which are a superset of context-free grammars.
Anyway, there's more than context-free grammars. Definitely look into data-dependant grammars! It's able to define a lot of interesting things, like network protocol formats where you parse the length of the payload, then based on that length you know how much to interpret as the payload before you read the footer of the message. And you write all of that in a more declarative way and get a nice parser generated from it.
There is so much more, but I think I should to stop now :)
Parser combinators are a relatively modern topic in parsing. First research into the topic was made in the late 1980s, but parser combinators became popular after Parsec [0], a practical implementation of parser combinators in Haskell. These ideas have been borrowed to many different implementations in different languages since then.
[0] Daan Leijen: "Parsec, a fast combinator parser", 2001 http://research.microsoft.com/en-us/um/people/daan/download/...
I think it - and similar books - should be banished from first compiler classes, and instead be used later. Much more practical books like Wirth's that actually walk you through writing a compiler would be much better for most beginners.
That's kind of a desperation move. There was a COBOL compiler for the IBM 1401 with about 79 passes.[1] They only had 4K or so of memory, but they had fast tape drives, so each pass read the previous intermediate form, did some processing on it, and wrote out the next intermediate form. Except for the passes which did sorts.
[1] http://bitsavers.informatik.uni-stuttgart.de/pdf/ibm/140x/C2...
And if yoy worry about performance, your compiler can fuse passes together in many cases.
My own experience with far fewer stages is that while it becomes easy to understand what each stage does and how, it becomes hard to keep track of how each stage interact, as each intermediate output in effect becomes a language dialect of sorts.
I'm still not decided on whether it's a net win or loss.
Check it out - a list of all the phases in the source! https://github.com/lampepfl/dotty/blob/master/src/dotty/tool...
It really helps that they are different, you always know which stage you're in.
Earlier you wrote that "And if you worry about performance, your compiler can fuse passes together in many cases."
What kind of fusion do you use in your approach?
In the last paragraph of Keep's thesis, he mentions pass fusion, citing Wadler's 1988 work on deforestation. Keep does not, however, give a working implementation.
I know of no-one doing nanopass fusion, but I'd love to be corrected.
It is using an IR which allows to easily compare the visitor shapes (i.e., source AST, target AST, traversing order, omitted nodes), and if they do match and the second pass is not using results of any of the collectors from the first pass (too long to explain what collectors are, treat them as a kind of a constrained side effect), corresponding visitor nodes are chained together.
Deforestation is supposed to work on much lower level, and I'm not sure it's possible to deduce the original visitor shapes and traversing order from an already lowered code.
A previous version of this framework is available on github, username 'combinatorylogic' (it does not provide fusion or any other optimisations, because it's dynamically typed; A glimpse of the IR of the next version can be seen in the recform AST library, see 'ast2-...'). The current version will be published soon.
https://github.com/combinatorylogic/clike
Because you have a functional and also imperative language (pfront?) that your compiler is written in, you avoid memory churn by ensuring "visitor nodes are chained together", is that right?
So pass fusion for compiling clike does away with the destructive matching and rebuilding of untransformed subexpressions by linking pointers back to the original.
Yes, but as Wirth showed already in the 70's, you don't even need an IR in order to do this, much less separate passes.
For a highly optimizing compiler like yours the complexity might have been unavoidable anyway, though (a lot of the simplicity of Wirth's compilers comes from a long held insistence that no optimization could be added to the compiler unless it sped up the compilation of the compiler itself - in other words, it needed to be simple enough and cheap enough to apply to the compiler source code to pay for itself... needless to say this implicitly means that most Wirth-compilers omit a lot of optimizations that are usually included elsewhere, though some of his students did implement some impressive "unofficial" optimizing variations of his compilers that were also blazing fast)
As compilers go, I don't think gcc has been a good example of maintainable software for a very long time, if ever.
[1] http://norvig.com/lispy.html [2] http://norvig.com/lispy2.html
I'd really like to get a chance to take the time to retrospectively go over and tighten it up. The trouble with that is that it's easily 10 times+ as much effort to follow along with a project this way and write about each change as it is to cover the finished code (especially thorny issues such as how to avoid wasting too much time on bug fixes that may or may not have affected understanding of previous parts).
Personally, while I'm very happy that people find my series useful, I'd recommend perhaps supplementing or starting with Niklaus Wirth's books or Crenshaws "Let's write a compiler" (one of the ones recommended in the linked artile) for something much more focused. I feel the angles are sufficiently different that it's worth it. And Wirth is my big hero when it comes to language and compiler construction.
While the other suggestion in the article is well worth a read, I'd give one caveat: Lots of passes is hard to keep mental track of. My compiler sort-of does something similar in applying multiple transformation stages to the AST, and I'm mulling collapsing it into fewer stages rather than more because I feel it's getting more complex than necessary. You can work around some of the debugging complexity by outputting each stage to a suitable format so you can test each transformation individually, but it's still hard to keep track of exactly what each stage expects and delivers.
Honestly this sounds like a good case for a type system - which of course isn't available in ruby.
The problem is that either you create specialized IR's for each stage, or a lot of this is expressed through tree shape, and it's just painful to mentally keep track of more than it is painful to test.
Compilers are far simpler than that. You do not need a Turing-complete language to write a compiler.
Is very good and shows different strategies for the runtime.
1: http://cs.brown.edu/courses/cs173/2012/ 2: http://www.eopl3.com/
I'd definitely recommend it highly; the only way it could be better is if it arrived at a compiler that could compile itself.
Another article which I think everyone attempting to write a compiler should read is https://www.engr.mun.ca/~theo/Misc/exp_parsing.htm - how to simply and efficiently parse expressions.
The idea of doing tons of compiler passes works well in a functional context, where functional separation i the preferred method of abstraction, and you can use composition to effectively transform all your passes into a single pass.
However, actually doing that many passes seems like it is going to end badly. Even if you don't touch disk, you're doing mean things to the memory subsystem...
Also, for the trivial rewrites (e.g., variable renaming) your compiler can safely choose to replace a cooying rewrite with a destructive update.
To be clear: if you are thrashing a single graph over-and-over again, you are doing many passes. Rewriting and compacting trees is just a way to make the multiple passes a bit less painful.
A single "pass" can be thrashing a graph many times too, think of the things like instcombine (in LLVM parlance) or ADCE.
Essentially, compilation is rewriting of a source AST into the target machine code. It is up to you how to structure this rewrite, but the amount of work to be done remains the same.
For a human it is much easier to structure such a rewrite as a sequence of very trivial changes. And a "sufficiently smart" compiler must realise that it can be done in less steps.
While that is true of most modern compilers, do consider that there's the whole Wirth school of compilers that don't use an AST at all but generate machine code directly during parsing.
> For a human it is much easier to structure such a rewrite as a sequence of very trivial changes.
I'm not convinced, as I've written elsewhere, because while the changes may be trivial, you need to keep track of what a program is expected to look like in between each stage, and that becomes harder the more stages you introduce. E.g. my forever-in-progress Ruby compiler has a stage where it makes closures explicit: It allocates an environment to store variables in and rewrites variable accesses accordingly. For following stages the language I'm working on is different: I can no longer use closures.
The more stages you have, the more language dialects you need to manage to mentally separate when working on the stages. And if you decide you need to reorder stages, you often have painful work to adapt the rewrites to their new dialects.
The end result looks very simple. But try to make changes and it's not necessarily nearly as simple.
It is not any different. A "sufficiently smart" compiler must be capable of fusing the lowering passes straight into a parser. Parsing is not that different from the other kinds of passes.
> that becomes harder the more stages you introduce
My usual trick is to have a "unit test" simple AST which, when building in a certain mode, is fed into the compilation pipeline and displayed with all the annotations. It helps a lot to understand what exactly each pass is doing. In the next version of my DSL for building compilers (currently a work in progress) it will even be a part of an IDE.
> the more language dialects you need to manage to mentally separate when working on the stages
Ideally, one pass = one language. Nanopass handles it nicely.
I am using this approach for pretty much all kinds of languages, and never had any issues with too many IRs. In my book, the more distinct IRs, the better.
Given the state of compiler-generation tools (i.e. they're not even viable for generating parsers for production quality compilers), I have little hope of seeing a tool like that in the next couple of decades at least. Probably longer. At least in a form I'll be able to use for anything.
> Ideally, one pass = one language. Nanopass handles it nicely.
That was exactly what I see as a nightmare of a maintenance problem. Even with half a dozen or so variations at most, I find it annoying.
E.g., there is no point in keeping a one-handed 'if' in a language after a pass which lowers it into a two-handed 'if'. There is no point in keeping a 'lambda' node in your AST after lambda lifting is done.
E.g. even if "x = 1; y = lambda { puts x } " is technically legal in all stages, if one of the stages is responsible for "lowering" the above to an explicit environment allocation and rewriting accesses to x, then anything below that which tries to use the "lambda {}" syntax would fail to compile.
See, the problem is, for the "sufficiently smart" compiler to easily realize this is possible, the passes need to be purely functional. It turns out, most programmers struggle with understanding functional programming...
Although, my approach allows to analyse sufficiently imperative passes too: I've got special cases for "collectors" (lists accumulating ordered data imperatively which theb can be read once), maps (same restriction - all the writes must be done before the first read) and all kinds of destructive assignment to the simple variables.
"The idea of doing tons of compiler passes works well in a functional programming context, where functional separation is the preferred method of abstraction, and you can use composition to effectively transform all your passes into a single pass."
Yes, you need to take care of garbage collection (in one way or another) when you're manipulating tree and graph structures.
I can't share your opinion about "ending badly". This has been shown to be a good strategy in practice.
Take a look at http://www.compilerworks.com/dev.html
Hmm, I personally found the "Dragon Book" very accessible. And I'm someone without formal CS education.