Swift Tip: Extensible Libraries with Protocols
In Swift Talk 89, Brandon Kase showed us an interesting technique to build extensible libraries with protocol composition. We already use this technique in the code that generates our books from Markdown files. Here's a simplified example that shows how it works, and how flexible it is.
Let's say we've defined the Markdown elements as an enum:
enum Block {
case heading(String)
case paragraph(String)
}
It's easy to write an extension on Block
with an HTML render method:
extension Block {
func renderHTML() -> String {
switch self {
case let .heading(text): return "\(text)"
case let .paragraph(text): return "\(text)"
}
}
}
Since we want to use this code in multiple places, it lives in its own library. With the enum
approach, we're limited in our ability to extend this library from the outside, without modifying the shared library code. We can add a new interpretation (e.g. a LaTeX renderer or an attributed string renderer), but we can't add a new case to the enum. Sometimes that's exactly what we want to do, because some output formats have capabilities that others don't (e.g. a page break in LaTeX).
To make the library extensible from the outside without having to modify the source code, we use the technique demonstrated in Swift Talk 89. To start with, we change Block
from an enum to a protocol:
protocol Block {
static func heading(_ text: String) -> Self
static func paragraph(_ text: String) -> Self
}
Each enum case is now a static func
returning Self
. Rendering is also slightly modified. First, we define an HTML renderer struct:
struct HTMLRenderer {
let rendered: String
}
Now we extend the renderer to conform to the Block
protocol:
extension HTMLRenderer: Block {
static func heading(_ text: String) -> HTMLRenderer {
return HTMLRenderer(rendered: "\(text)")
}
static func paragraph(_ text: String) -> HTMLRenderer {
return HTMLRenderer(rendered: "\(text)")
}
}
To test this out, we first define a function that builds a Markdown document, returning an array of Block
s:
func createDocument<B: Block>() -> [B] {
return [B.heading("Hello World!"), B.paragraph("My first document.")]
}
We can pass this document into a function that renders it as HTML:
func renderHTML(_ renderers: [HTMLRenderer]) -> String {
return renderers.map { $0.rendered}.joined(separator: "\n")
}
print(renderHTML(createDocument()))
/*
Hello World!
My first document.
*/
If we want to render the same document to LaTeX, we can write a LaTeX renderer without changing the core library:
struct LatexRenderer {
let rendered: String
}
extension LatexRenderer: Block {
static func heading(_ text: String) -> LatexRenderer {
return LatexRenderer(rendered: "\\section{\(text)}")
}
static func paragraph(_ text: String) -> LatexRenderer {
return LatexRenderer(rendered: text )
}
}
So far, we haven't achieved anything new. However, we can now add new elements without touching the core library. For example, we can specify block level elements specific to the LaTeX format:
protocol LatexBlock {
static func pageBreak() -> Self
}
We only conform the LaTeX renderer to the LatexBlock
protocol, since the HTML renderer cannot make sense of these elements:
extension LatexRenderer: LatexBlock {
static func pageBreak() -> LatexRenderer {
return LatexRenderer(rendered: "\\pagebreak")
}
}
To create a document that contains the LaTeX specific elements, we have to explicitly state that the returned elements conform to Block
as well as LatexBlock
:
func createLatexDocument<B: Block & LatexBlock>() -> [B] {
return [
B.heading("Hello World!"), B.paragraph("My first document."),
B.pageBreak(),
B.paragraph("Second page.")
]
}
Trying to render a document with LatexBlock
elements to HTML will now give us a compile time error. Rendering it to LaTeX works as expected:
func renderLatex(_ renderers: [LatexRenderer]) -> String {
return renderers.map { $0.rendered }.joined(separator: "\n\n")
}
print(renderLatex(createLatexDocument()))
/*
\section{Hello World!}
My first document.
\pagebreak
Second page.
*/
This technique allows us to have all the standard Markdown elements defined in a library, while giving us the freedom to extend it in all dimensions if needed: we can add new renderers for different formats, and we can add new elements that can be rendered only to specific formats. And it's all type-safe!
If you'd like to learn about other use cases for protocol composition, subscribe to watch the full episode.