Pretty printing custom types in Julia
Navigating the display system to be consistently flash
Say you're writing a package, it's working well, but you want to give people exploring it interactively a good experience — not just flooding the terminal/notebook/etc. with a wall of dense black and white text. You can customise how your types are displayed by strategically overloading certain functions, but which functions, and how should you implement your custom display methods?
The manual has a section on custom pretty printing, but it doesn't quite cover everything1. Here I'll try to fill that gap in a causal post about thoughtful pretty printing, for my own benefit if nothing else.
2. A motivating example
To dive into the details of display in Julia, we'll be well-served by an example. Something nice an simple, but still able to represent the complexity of a thoughtful approach. Let's say we have a document manipulation package, and as part of that we might extract a paragraph of text that's been decomposed into sentences.
If we expect people using our package to see Paragraph
s a lot, we might want to
put some thought into how they are printed.
3. The importance of round-trip representation
Without us defining any show
methods (or similar), Julia will already present a
sensible representation of a Paragraph
:
julia
julia> Paragraph(["Son coeur est un luth suspendu.", "Sitot qu'on le touche il resonne."]) Paragraph(["Son coeur est un luth suspendu.", "Sitot qu'on le touche il resonne."])
At this point, I think it is worth mentioning the Lisp family of languages. While we recognise a collection of languages (Common Lisp, Clojure, Racket, and more) as members of the Lisp family, often languages are referred to a Lisp-like. Julia is often referred to as a particularly Lisp-y language, but what does this mean? Some years ago, Kent Pitman (a prolific Lisp operator) insightfully opined in a Slashdot interview2:
I like Lisp's willingness to represent itself. People often explain this as its ability to represent itself, but I think that's wrong. Most languages are capable of representing themselves, but they simply don't have the will to. Lisp programs are represented by lists and programmers are aware of that. It wouldn't matter if it had been arrays. It does matter that it's program structure that is represented, and not character syntax, but beyond that the choice is pretty arbitrary. It's not important that the representation be the Right® choice. It's just important that it be a common, agreed-upon choice so that there can be a rich community of program-manipulating programs that "do trade" in this common representation.
I think it is fair to say that Julia, by design, is quite Lisp-y. The choice to display types in a form that can be directly evaluated to reproduce the original value is a key part of this. Julia's willingness to "represent itself" is one of its strengths, and so it behoves us to take note of this characteristic.
4. Bring out the bling
I find one of the more fun stages of package development to be when I take an object that I've worked hard on, and put a small portion of that effort towards making it look good in the REPL, showcasing some of the work I've put into it.
A good way to do this (particularly in Julia 1.11+) is with the StyledStrings
standard library. It makes nested and repeated styling more convenient, styling
more compossible across packages/functions, and allows for user-customisation of
styles3. For our prettiest representation, we want to implement the
show(::IO, ::MIME"text/plain", para::Paragraph)
method.
julia
function Base.show(io::IO, ::MIME"text/plain", para::Paragraph) show(io, Paragraph) nwords = sum(s -> length(split(s)), para.sentences, init=0) parastr = escape_string(join(para.sentences, ' ')) print(io, styled"({light:{emphasis:$(length(para.sentences))} sentences, {emphasis:$nwords} words:} {bright_green:\"{italic:$parastr}\"})") end
This could also be done with printstyled
, it's just a little more tedious.
Almost equivalent pretty display without StyledStringsjulia
function Base.show(io::IO, ::MIME"text/plain", para::Paragraph) show(io, Paragraph) nwords = sum(s -> length(split(s)), para.sentences, init=0) parastr = escape_string(join(para.sentences, ' ')) print(io, '(') get(io, :color, false) === true && print(io, "\e[22m") printstyled(io, length(para.sentences), color=:blue) print(io, " sentences, ") printstyled(io, nwords, color=:blue) print(io, " words:") get(io, :color, false) === true && print(io, "\e[0m") print(io, ' ') printstyled(io, '"', color=:light_green) printstyled(io, parastr, color=:light_green, italic=true) printstyled(io, '"', color=:light_green) print(io, ')') end
I quite like the addition of this little summary at the start. For more complex types, I'd expect a more sophisticated transformation for pretty display, but we're using a simple example for good reason here 🙂.
Two sample paragraphs on the Antikythera Mechanismjulia
julia> Paragraph([...]) Paragraph(4 sentences, 97 words: "In spring 1900, a party of sponge divers took shelter from a violent Mediterranean storm. When the storm subsided, they dived for sponges in the local waters near the tiny island of Antikythera, between Crete and mainland Greece. By chance, they found a wreck full of ancient Greek treasures, triggering the first major underwater archaeology operation in history. Overseen by a gunboat from the Greek navy to deter looters, by early 1901 the divers had begun to recover a wonderful array of ancient Greek goods – beautiful bronze sculptures, superb glassware, jewellery, amphorae, furniture fittings, and tableware.") julia> Paragraph([...]) Paragraph(3 sentences, 65 words: "They also found an undistinguished lump, the size of a large dictionary, which was probably recovered because it looked green, suggesting bronze. It was not considered to be anything remarkable at the time. Now, though, it is recognised as by far the most important object of high technology ever recovered from the ancient world: an ancient Greek astronomical calculating machine, known as the Antikythera Mechanism.")
It's also worth taking note of my use of show(io, Paragraph)
over
show(io, "Paragaph")
. You don't know whether your package will be
loaded into the Main
environment, or within another package. Using
show(io, Paragraph)
ensures that it will be properly prefixed, if
appropriate.
5. Limiting display length
6. Compact forms
You're pretty happy with this pretty printing, but then you discover Contrapunctual Poems (1, 2).
Before leaving the house Wandering these empty rooms I saw the storms approach dark and devoid of life-- and thought here it comes: lonely moments to bend. the end, a bright flash Of every lost love letter, of light across my face. I remember the desire Then, I heard the thunder. shaking me inside and out.
I try not to think of you but The memories stick to me it’s not so easy to walk away and in my cloudy mental haze Nothing makes sense without a hand to hold so I struggle through the cold and I start to lose my place somewhere beside you where I used to be a fading familiar memory that space from yesteryear I wish you were here but I will move on
It occurs to you that, if you pretend stanzas are basically Paragraph
s, you
could store a list of contrapunctual poems like these in a two-column matrix.
julia
julia> [Paragraph(a1) Paragraph(a2); Paragraph(b1) Paragraph(b2)] 2×2 Matrix{Paragraph}: Paragraph(6 sentences, 29 words; "Before leaving the house I saw the storms approach and thought here it comes: the end, a bright flash of light across my face. Then, I heard the thunder.") … Paragraph(6 sentences, 27 words; "Wandering these empty rooms dark and devoid of life-- lonely moments to bend. Of every lost love letter, I remember the desire shaking me inside and out.") Paragraph(7 sentences, 37 words; "I try not to think of you but it’s not so easy to walk away Nothing makes sense so I struggle through the cold and somewhere beside you a fading familiar memory I wish you were here") Paragraph(7 sentences, 36 words; "The memories stick to me and in my cloudy mental haze without a hand to hold I start to lose my place where I used to be that space from yesteryear but I will move on")
Aside from the fact it's hard to read these poems with the linebreaks removed,
the two-column display of Matrix
has been somewhat broken by our custom show
method. That's not good!
Thankfully, it's a fairly easy problem to remedy. To signal that an object
should be printed in a one-line compact representation, Julia uses the
:compact
property. We can check this and show only the paragraph
summary (number of sentences and words) when it is set.
julia
function Base.show(io::IO, ::MIME"text/plain", para::Paragraph) show(io, Paragraph) nwords = sum(s -> length(split(s)), para.sentences, init=0) print(io, styled"({light:{emphasis:$(length(para.sentences))} sentences, {emphasis:$nwords} words}") if get(io, :compact, false) !== true parastr = escape_string(join(para.sentences, ' ')) print(io, styled": {bright_green:\"{italic:$parastr}\"}") end print(io, ')') end
Now let's see how that looks4.
julia
julia> [Paragraph(a1) Paragraph(a2); Paragraph(b1) Paragraph(b2)] 2×2 Matrix{Paragraph}: Paragraph(6 sentences, 29 words) Paragraph(6 sentences, 27 words) Paragraph(7 sentences, 37 words) Paragraph(7 sentences, 36 words)
Much more compact, indeed. This should let a large collection of paragraphs be displayed reasonably.
7. Display when nestled within another type
8. Concise forms
At a glance this seems great, but then you look at a vector of long paragraphs. It's not pretty. You're taken back to a darker time, when you naïvely asked R to show you the contents of a data matrix 😨.
The Wikipedia introduction on the Guttemburg pressjulia
julia> gutenberg 4-element Vector{Paragraph}: Paragraph(3 sentences, 84 words; "A printing press is a mechanical device for applying pressure to an inked surface resting upon a print medium (such as paper or cloth), thereby transferring the ink. It marked a dramatic improvement on earlier printing methods in which the cloth, paper, or other medium was brushed or rubbed repeatedly to achieve the transfer of ink and accelerated the process. Typically used for texts, the invention and global spread of the printing press was one of the most influential events in the second millennium.") Paragraph(4 sentences, 98 words; "In Germany, around 1440, the goldsmith Johannes Gutenberg invented the movable-type printing press, which started the Printing Revolution. Modelled on the design of existing screw presses, a single Renaissance movable-type printing press could produce up to 3,600 pages per workday, compared to forty by hand-printing and a few by hand-copying. Gutenberg's newly devised hand mould made possible the precise and rapid creation of metal movable type in large quantities. His two inventions, the hand mould and the movable-type printing press, together drastically reduced the cost of printing books and other documents in Europe, particularly for shorter print runs.") Paragraph(5 sentences, 107 words; "From Mainz, the movable-type printing press spread within several decades to over 200 cities in a dozen European countries. By 1500, printing presses in operation throughout Western Europe had already produced more than 20 million volumes. In the 16th century, with presses spreading further afield, their output rose tenfold to an estimated 150 to 200 million copies. By the mid-17th century, the first printing presses arrived in colonial America in response to the increasing demand for Bibles and other religious literature. The operation of a press became synonymous with the enterprise of printing and lent its name to a new medium of expression and communication, \"the press\".") Paragraph(5 sentences, 125 words; "The spread of mechanical movable type printing in Europe in the Renaissance introduced the era of mass communication, which permanently altered the structure of society. The relatively unrestricted circulation of information and (revolutionary) ideas transcended borders, captured the masses in the Reformation, and threatened the power of political and religious authorities. The sharp increase in literacy broke the monopoly of the literate elite on education and learning and bolstered the emerging middle class. Across Europe, the increasing cultural self-awareness of its peoples led to the rise of proto-nationalism and accelerated the development of European vernaculars, to the detriment of Latin's status as lingua franca. In the 19th century, the replacement of the hand-operated Gutenberg-style press by steam-powered rotary presses allowed printing on an industrial scale.")
9. Taking note of terminal dimensions
10. TLDR; What do I do, when?
Footnotes:
Who knows, perhaps my writing here could be smartened up and brought into the manual at some point?
A comment that I only know about thanks to a StackOverflow answer of Stefan Karpinski, on homoiconicity and Julia.
It's worth noting that StyledStrings is currently marked as experimental, with the hope that the method of declaring/using faces will be improved in the next release or two. All other parts of the API are expected to be left alone.
Unfortunately this isn't quite what you'll see if you try it yourself with Julia 1.11.1, due to what looks like a display bug. 🐛