Building DSLs in Swift
A DSL, short for Domain Specific Language, can be explained as a special kind of API that focuses on providing a simple syntax that's tailored to working within a specific domain. Rather than being complete stand-alone languages - like Swift is - DSLs are often hosted in other languages, and as such, need to use a grammar that's also perfectly valid in their host language.
DSLs are particularly popular among customizable developer tools - CocoaPods, fastlane and the Swift Package Manager all use DSLs to enable their users to easily set up how they want the tool to work. But DSLs can also be used to make it easier to work with many more kinds of domains - like querying databases, defining layout, or setting up some form of routing.
While historically DSLs have often been written in more dynamic languages, such as Ruby (since they offer a lot of ways to create custom syntax) - Swift's type inference and overloading capabilities also make it a really great language to build DSLs in - and this week, let's do just that!
Lightweight syntax
One of the main advantages of working with a DSL is that it gives us a much more lightweight syntax than what we'd normally get when using standard APIs. For example, when using CocoaPods - we use a Podfile
to define how to configure a project's dependencies, using what at first might look like a serial format:
pod "Unbox"
pod "Files", "~> 2.2"
But it turns out that the above is actually fully executable Ruby code, and that pod
is in fact a function rather than just a simple marker. That might seem like some form of magic, but just like with any other technological magic - it's all simply code under the hood.
So, let's build our own DSL! Let's take a common task, that usually requires a quite verbose syntax, and try to distill it down into something almost as lightweight as what CocoaPods lets us do.
One task that perfectly fits this description is defining layout constraints using Auto Layout. While Auto Layout's API has been vastly improved over the years - especially with the introduction of layout anchors in iOS 9 - it's still quite verbose and heavy, even for simple tasks like defining a position and width for a UILabel
based on a button sibling and its parent view:
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.topAnchor.constraint(
equalTo: button.bottomAnchor,
constant: 20
),
label.leadingAnchor.constraint(
equalTo: button.leadingAnchor
),
label.widthAnchor.constraint(
lessThanOrEqualTo: view.widthAnchor,
constant: -40
)
])
Let's see if we can take the above code, and by building a DSL for it, transform it into this instead:
label.layout {
$0.top == button.bottomAnchor + 20
$0.leading == button.leadingAnchor
$0.width <= view.widthAnchor - 40
}
That'd let us define layout constraints so much easier, and would remove a lot of the "syntactic cruft". Achieving something like the above might seem like a huge task, but once we start breaking it down into smaller building blocks, it's actually quite simple. Let's go through the process step-by-step.
Laying the ground work
Let's start by constructing the foundation on top of which we'll later build our DSL. What we essentially want to do is wrap Auto Layout's default layout anchor-based API in a "DSL shell", that still produces completely normal layout constraints when called.
All layout anchors are implemented using the NSLayoutAnchor
class - which is a generic, since different anchors act differently depending on whether they're used for things like positioning or size. Since Objective-C generics are not quite as powerful as Swift ones, let's start by defining a protocol that'll let us treat NSLayoutAnchor
as if it was a native Swift type.
We'll define our protocol by taking the methods we're interested in using, and adding them as requirements, like this:
protocol LayoutAnchor {
func constraint(equalTo anchor: Self,
constant: CGFloat) -> NSLayoutConstraint
func constraint(greaterThanOrEqualTo anchor: Self,
constant: CGFloat) -> NSLayoutConstraint
func constraint(lessThanOrEqualTo anchor: Self,
constant: CGFloat) -> NSLayoutConstraint
}
Since NSLayoutAnchor
already implements the above methods, all we need to do to make it conform to our new protocol is to simply add an empty extension:
extension NSLayoutAnchor: LayoutAnchor {}
The above technique, which essentially hides a system type behind a protocol that we control, is the same one used in "Testing Swift code that uses system singletons in 3 easy steps" to enable us to easily test code that relies on system-provided singletons.
Next, we need a way to refer to a single anchor in a simpler manner. To do that, we're going to define a LayoutProperty
type, which we'll be able to use in our DSL to set up constraints for properties like top
, leading
, width
, etc.
This new type will simply be a wrapper around an anchor, since we don't want to "pollute" the NSLayoutAnchor
type itself with lots of extensions in order to make our DSL work. Since we now have a protocol that lets us refer to layout anchors in a type safe way, we'll use that as a generic constraint for our new type, like this:
struct LayoutProperty<Anchor: LayoutAnchor> {
fileprivate let anchor: Anchor
}
We make the above anchor
property fileprivate
, so that it's only accessible within the file we're defining our layout DSL in, which stops us from leaking implementation details to the outside world.
Now that we have a way to handle both anchors and properties - let's get to the heart of our DSL, which is an object that'll act as a proxy for the view that we're currently defining layout for. This object will contain all layout properties, and will be the key object that we'll interact with when using our DSL. Let's call it LayoutProxy
, and start by defining properties for some common anchors - like leading
, top
and width
:
class LayoutProxy {
lazy var leading = property(with: view.leadingAnchor)
lazy var trailing = property(with: view.trailingAnchor)
lazy var top = property(with: view.topAnchor)
lazy var bottom = property(with: view.bottomAnchor)
lazy var width = property(with: view.widthAnchor)
lazy var height = property(with: view.heightAnchor)
private let view: UIView
fileprivate init(view: UIView) {
self.view = view
}
private func property<A: LayoutAnchor>(with anchor: A) -> LayoutProperty<A> {
return LayoutProperty(anchor: anchor)
}
}
Above, we make all of our layout properties lazy
, so that they're only constructed when needed. Especially if we keep adding support for more kinds of anchors, this can help make our code faster and less wasteful.
We're almost done with the ground work for our DSL. The final thing we need is an API for using our layout properties to add constraints. For that we'll use an extension on LayoutProperty
, and add support for the three kinds of constraint relationships that we initially extracted from NSLayoutAnchor
into our LayoutAnchor
protocol:
extension LayoutProperty {
func equal(to otherAnchor: Anchor, offsetBy constant: CGFloat = 0) {
anchor.constraint(equalTo: otherAnchor,
constant: constant).isActive = true
}
func greaterThanOrEqual(to otherAnchor: Anchor,
offsetBy constant: CGFloat = 0) {
anchor.constraint(greaterThanOrEqualTo: otherAnchor,
constant: constant).isActive = true
}
func lessThanOrEqual(to otherAnchor: Anchor,
offsetBy constant: CGFloat = 0) {
anchor.constraint(lessThanOrEqualTo: otherAnchor,
constant: constant).isActive = true
}
}
And with that, we're ready to take our new Auto Layout API for a first spin! 👍
From API to DSL
We might not have a full DSL yet, but with our ground work completed we can already start using our code like we would a "normal" API. All we have to do is to manually create an instance of LayoutProxy
for the view that we wish to define layout for, and then call methods on its layout properties to add constraints, like this:
let proxy = LayoutProxy(view: label)
proxy.top.equal(to: button.bottomAnchor, offsetBy: 20)
proxy.leading.equal(to: button.leadingAnchor)
proxy.width.lessThanOrEqual(to: view.widthAnchor, offsetBy: -40)
Compared to the default Auto Layout API, that's already a huge reduction in verbosity! However, while our methods read quite nicely when used like above, the whole code looks a little bit out of place. It feels a bit strange to have to construct a proxy object just to define layout, and calls like proxy.top.equal
don't really make a lot of sense without knowing about the implementation details of our API.
So let's take things one step further, and enable our above code to be used as a proper DSL. The first thing we'll need is an execution context. One reason that DSLs can remove so much verbosity and cruft, is that they're used in a very specific context, that itself already provides much of the information required to understand what the code does. When we see pod "Unbox"
in a Podfile
, we immediately understand that we're adding the pod Unbox to our project, since we know that we're currently within the context of the CocoaPods DSL.
For our context, we'll take some inspiration from the UIView.animate
API, and use a closure to encapsulate the usage of our DSL. All we need to make that happen is a simple extension on UIView
that adds a method that in turn calls our context closure. We'll also take this opportunity to automatically set translatesAutoresizingMaskIntoConstraints
to false
, which further makes our API easier to use, like this:
extension UIView {
func layout(using closure: (LayoutProxy) -> Void) {
translatesAutoresizingMaskIntoConstraints = false
closure(LayoutProxy(view: self))
}
}
With just that small change, we now have a much more "DSL-like" experience when defining layout, as we're performing all of our layout operations within a specific context. Here's what our code from before now looks like:
label.layout {
$0.top.equal(to: button.bottomAnchor, offsetBy: 20)
$0.leading.equal(to: button.leadingAnchor)
$0.width.lessThanOrEqual(to: view.widthAnchor, offsetBy: -40)
}
Pretty sweet! 🍭 If we wanted to, we could stop right here and still be very happy with the result. We've essentially built a really nice and easy to use Auto Layout library in just 60 lines of code - and compared to our original layout code the code right above is much less verbose and nicer to read.
But we're having fun, so why stop now? Let's see if we can take things even further! 😉
Hello, Operator!
The fact that Swift lets us define custom operators can be a bit of a double-edged sword, but when it comes to DSLs, custom operators - or in this case, operator overloads - can be a fantastic tool. If we think about it, DSLs are often used to perform and evaluate expressions - like defining the minimum version of a dependency, adding a filter to a database query, or (like in our case) computing layout - and expressions are pretty much exactly what operators are most commonly used for.
Let's see how we can improve our DSL using operators - starting with overloading the plus and minus operators to enable us to combine a layout anchor and a constant into a tuple - which'll later let us act on them as one unit:
func +<A: LayoutAnchor>(lhs: A, rhs: CGFloat) -> (A, CGFloat) {
return (lhs, rhs)
}
func -<A: LayoutAnchor>(lhs: A, rhs: CGFloat) -> (A, CGFloat) {
return (lhs, -rhs)
}
For more on tuples - check out "Using tuples as lightweight types in Swift".
Next, let's add overloads that'll let us actually define constraints - starting with the equals operator. We'll need two overloads, one that accepts just an anchor on the right hand side, and one that accepts one of the tuples we're producing with our previous two overloads - like this:
func ==<A: LayoutAnchor>(lhs: LayoutProperty<A>,
rhs: (A, CGFloat)) {
lhs.equal(to: rhs.0, offsetBy: rhs.1)
}
func ==<A: LayoutAnchor>(lhs: LayoutProperty<A>, rhs: A) {
lhs.equal(to: rhs)
}
As you can see above, all we're really doing with these operators is using them as syntactic sugar on top of our previous layout API. Let's also do the same for the greater than or equals and the less than or equals operators as well:
func >=<A: LayoutAnchor>(lhs: LayoutProperty<A>,
rhs: (A, CGFloat)) {
lhs.greaterThanOrEqual(to: rhs.0, offsetBy: rhs.1)
}
func >=<A: LayoutAnchor>(lhs: LayoutProperty<A>, rhs: A) {
lhs.greaterThanOrEqual(to: rhs)
}
func <=<A: LayoutAnchor>(lhs: LayoutProperty<A>,
rhs: (A, CGFloat)) {
lhs.lessThanOrEqual(to: rhs.0, offsetBy: rhs.1)
}
func <=<A: LayoutAnchor>(lhs: LayoutProperty<A>, rhs: A) {
lhs.lessThanOrEqual(to: rhs)
}
With that final piece of the puzzle, our DSL is now complete, and we're now able to use our new operator overloads to define all of our layout constraints simply using expressions:
label.layout {
$0.top == button.bottomAnchor + 20
$0.leading == button.leadingAnchor
$0.width <= view.widthAnchor - 40
}
Compare that to the original code sample - the difference in verbosity is huge! 😮
Conclusion
Domain Specific Languages can be a great way to model a narrow problem using a lightweight syntax. While they require a bit of setup, and finding the right balance between low verbosity and easily understandable code can be really difficult, they can offer a huge boost in productivity in certain contexts.
Especially when paired with a declarative, rule-based system - like Auto Layout - DSLs can be really powerful, since they basically let us write our code as pure logical expressions, rather than having to construct full method calls. The important thing, however, is to make sure that our DSLs remain constrained within a certain context, since their low-verbosity syntax really depends on its surroundings in order to be easy to understand.
Another thing that's important to consider is compilation time. Especially when using overloaded operators and relying on type inference to reduce verbosity, compile times can sometimes drastically increase. Like always, measuring things as we go becomes key, since most often there's a compromise to be found between low verbosity and fast compilation times.
If you're interested in having a look at more complete Auto Layout DSLs, here's a few popular ones (I recommend comparing their various approaches to this problem, it's truly fascinating to see the different tradeoffs made in each implementation):
You can also find all of the code from this article on GitHub here. It could act as a good starting point if you wish to build your own DSL, and is already quite capable, even though it's less than 100 lines of code.
What do you think? Do you like the idea of DSLs, have you ever built one of your own, or is it something you'll try out? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell.
Thanks for reading! 🚀