Home > Article > Backend Development > Pretty-Printing is Compilation
Wadler's A Prettier Printer is a classic functional pearl. However—whether due to Haskell's laziness or my own—I struggled to reimplement his ideas in other languages (or even in Haskell, five minutes after reading the paper). Thankfully, Lindig recognized this problem and brought fire to the masses in Strictly Pretty. Yet even that wasn't dumbed-down enough for me.
But after spending some more time tinkering with the ideas from both papers, I think I finally get the idea.
As the title suggests, we'll think of pretty-printing as a process of compilation (and execution) of programs written in an abstract "document language". Like most programming languages, this document language—which we'll call
Doc—features expressions that can be composed together; this makes it easy for humans to reason about. We'll compile expressions in Doc to instructions in a kind of assembly language (ASM). Instructions in ASM are much easier to transform into strings.
Here's a schematic view of the process:
As a concrete example, suppose we want to pretty-print the nested list:
['onions', ['carrots', 'celery'], 'turnips']
Here's a Doc program for doing so:
group( '[' + nest( 4, br() + "'onions'" + ',' + br(' ') + group( '[' + nest(4, br() + "'carrots'" + ',' + br(' ') + "'celery'") + br() + ']' ) + ',' + br(' ') + "'turnips'" ) + br() + ']' )
We'll meet group, nest, etc. shortly. For now it's enough to get a general feel for the document language.
This program is then compiled (with certain "architectural parameters" set) into the ASM instructions:
TEXT '[' LINE 4 TEXT "'onions'" TEXT ',' LINE 4 TEXT '[' TEXT '' TEXT "'carrots'" TEXT ',' TEXT ' ' TEXT "'celery'" TEXT '' TEXT ']' TEXT ',' LINE 4 TEXT "'turnips'" LINE 0 TEXT ']'
which are then interpreted as a string:
[ 'onions', ['carrots', 'celery'], 'turnips' ]
One important feature alluded to above is that the compiler has a configurable "architectural parameter", which is a target maximum line width. Depending on the value of this parameter, the compiler will emit different ASM instructions for the same Doc program. In the example above we used a target width of 30. If we'd used 20 instead, the emitted assembly instructions would be different, as would the resulting string:
[ 'onions', [ 'carrots', 'celery' ], 'turnips' ]
And if we'd used 60, it would be:
['onions', ['carrots', 'celery'], 'turnips']
The assembly language is straightforward, so we'll tackle it first. We'll think of ASM instructions as controlling a really simple printing device that's only capable of doing two things:
Hence ASM consists of only two instructions:
ASM programs are interpreted as strings by executing their instructions, one after the other. As an example, let's trace the execution of the program:
['onions', ['carrots', 'celery'], 'turnips']
We'll use a > to indicate the instruction being executed, and display the current output below. The ^ character will indicate the current location of the "printer head". We'll also use _ characters to indicate spaces, since these are otherwise difficult to track.
The first TEXT instruction causes the string 'hello' to be emitted:
group( '[' + nest( 4, br() + "'onions'" + ',' + br(' ') + group( '[' + nest(4, br() + "'carrots'" + ',' + br(' ') + "'celery'") + br() + ']' ) + ',' + br(' ') + "'turnips'" ) + br() + ']' )
LINE 2 then advances to the next line and indents the head by 2 spaces:
TEXT '[' LINE 4 TEXT "'onions'" TEXT ',' LINE 4 TEXT '[' TEXT '' TEXT "'carrots'" TEXT ',' TEXT ' ' TEXT "'celery'" TEXT '' TEXT ']' TEXT ',' LINE 4 TEXT "'turnips'" LINE 0 TEXT ']'
Then TEXT 'indented' causes 'indented' to be added:
[ 'onions', ['carrots', 'celery'], 'turnips' ]
Followed by ' world', due to TEXT ' world':
[ 'onions', [ 'carrots', 'celery' ], 'turnips' ]
LINE 0 advances the printer to the next line (and doesn't indent at all):
['onions', ['carrots', 'celery'], 'turnips']
And finally, TEXT 'goodbye' emits 'goodbye':
TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye'
We'll represent ASM instructions as a "sum type":
That is:
> TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello ^
Interpreting a list of AsmInsts into the string they represent is just a matter of iterating over the instructions and "doing the right thing":
TEXT 'hello' > LINE 2 TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello __ ^
For TEXT instructions, the interpreter appends the text to the result; for LINE instructions, the interpreter appends a newline ('n') followed by indent spaces.
We can test interpret with the ASM instructions from the example above, translated into Python:
TEXT 'hello' LINE 2 > TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello __indented ^
We like ASM because it's easy to interpret. But it's a pain to use. This motivates the more human-friendly Doc language. Whereas ASM programs are sequences of instructions, Doc programs are compositions of expressions. These expressions are summed up by the following grammar:
TEXT 'hello' LINE 2 TEXT 'indented' > TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello __indented world ^
For example:
TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' > LINE 0 TEXT 'goodbye' == OUTPUT == hello __indented world ^
is a Doc expression, as is:
TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' LINE 0 > TEXT 'goodbye' == OUTPUT == hello __indented world goodbye ^
What do these represent?
So, for instance:
AsmInst = str | int
represents the string:
def interpret(insts: list[AsmInst]) -> str: """Interpret the ASM instructions as a string.""" result = "" for inst in insts: match inst: case text if isinstance(text, str): result += inst case indent if isinstance(indent, int): result += f"\n{' ' * indent}" return result
The second, more complex expression:
['onions', ['carrots', 'celery'], 'turnips']
may represent either:
group( '[' + nest( 4, br() + "'onions'" + ',' + br(' ') + group( '[' + nest(4, br() + "'carrots'" + ',' + br(' ') + "'celery'") + br() + ']' ) + ',' + br(' ') + "'turnips'" ) + br() + ']' )
or:
TEXT '[' LINE 4 TEXT "'onions'" TEXT ',' LINE 4 TEXT '[' TEXT '' TEXT "'carrots'" TEXT ',' TEXT ' ' TEXT "'celery'" TEXT '' TEXT ']' TEXT ',' LINE 4 TEXT "'turnips'" LINE 0 TEXT ']'
depending on the value of the target maximum line width "architectural parameter" provided to the compiler. So br expressions might either be treated as line breaks or regular text, in which case their text value is used (or '' if no text argument was provided).
We'll represent Doc expressions using strs and Python classes. In particular:
[ 'onions', ['carrots', 'celery'], 'turnips' ]
What about DocExpr DocExpr? We'll represent those using an additional Concat class:
[ 'onions', [ 'carrots', 'celery' ], 'turnips' ]
We want to support using to combine expressions, so we need to implement __add__ and __radd__ on each of the variant classes. Adding two Doc expressions using just constructs a Concat of the two. It's easy enough to do this manually, e.g.:
['onions', ['carrots', 'celery'], 'turnips']
But we can save ourselves some typing by defining a decorator to do it for us:
TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye'
The variant classes now look like:
> TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello ^
Our task now is to write a compiler that translates expressions in the Doc language into the equivalent ASM instructions, given some target maximum line width:
TEXT 'hello' > LINE 2 TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello __ ^
However, it turns out to be simpler to first "lower" Doc expressions into expressions in an Intermediate Representation (IR) language, and then compile the IR expressions into ASM. Introduces this additional "pass" makes each step clearer.
So our schematic describing the pretty-printing process was a tad oversimplified. Here's the full picture:
IR expressions resemble Doc expressions in many ways:
TEXT 'hello' LINE 2 > TEXT 'indented' TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello __indented ^
The key difference is that we no longer have and nil expressions: these are converted to lists of IR expressions in the lowering process. Actually, that's all the lowering pass does:
TEXT 'hello' LINE 2 TEXT 'indented' > TEXT ' world' LINE 0 TEXT 'goodbye' == OUTPUT == hello __indented world ^
We first need to define IrExprs.
This ought to look familiar:
TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' > LINE 0 TEXT 'goodbye' == OUTPUT == hello __indented world ^
All lower does is replace Nil() instances with an empty list ([]), and Concat(car, cdr) instances by appending the results of lowering the car and cdr expressions. The same treatment is applied to the subexpressions in Nest and Group. This is nothing more than a recursive "flattening" operation.
TEXT 'hello' LINE 2 TEXT 'indented' TEXT ' world' LINE 0 > TEXT 'goodbye' == OUTPUT == hello __indented world goodbye ^
Testing lower with one of our example Doc expressions from above:
AsmInst = str | int
which is exactly what we expect.
Now to the final step: compile. This function transforms IR expressions into ASM instructions, taking into account the target maximum line width:
def interpret(insts: list[AsmInst]) -> str: """Interpret the ASM instructions as a string.""" result = "" for inst in insts: match inst: case text if isinstance(text, str): result += inst case indent if isinstance(indent, int): result += f"\n{' ' * indent}" return result
Here's the rough idea of the algorithm:
['onions', ['carrots', 'celery'], 'turnips']
process is where the magic happens:
group( '[' + nest( 4, br() + "'onions'" + ',' + br(' ') + group( '[' + nest(4, br() + "'carrots'" + ',' + br(' ') + "'celery'") + br() + ']' ) + ',' + br(' ') + "'turnips'" ) + br() + ']' )
In short:
How does fits_flat work? It simply walks through the instructions in the group, treating brs as text, and stopping when either:
TEXT '[' LINE 4 TEXT "'onions'" TEXT ',' LINE 4 TEXT '[' TEXT '' TEXT "'carrots'" TEXT ',' TEXT ' ' TEXT "'celery'" TEXT '' TEXT ']' TEXT ',' LINE 4 TEXT "'turnips'" LINE 0 TEXT ']'
We can finally click the pieces together:
[ 'onions', ['carrots', 'celery'], 'turnips' ]
The only remaining pieces of the pretty-printer interface are the document expression constructors:
[ 'onions', [ 'carrots', 'celery' ], 'turnips' ]
Let's try the example from the introduction:
['onions', ['carrots', 'celery'], 'turnips']
See the full source here.
? Under Construction ?
? Under Construction ?
The above is the detailed content of Pretty-Printing is Compilation. For more information, please follow other related articles on the PHP Chinese website!