Some problems are easier to solve by creating a customized programming language, or “domain-specific language.” While creating a DSL traditionally requires writing your own compiler, you can instead use result builders with Swift 5.4 to make your code both easier to read and maintain. We'll take you through best practices for designing a custom language for Swift: Learn about result builders and trailing closure arguments, explore modifier-style methods and why they work well, and discover how you can extend Swift's normal language rules to turn Swift into a DSL.
To get the most out of this session, it's helpful (though not necessary) to have some experience writing SwiftUI views. You won't need to know anything about parser or compiler implementation.
♪ Bass music playing ♪ ♪ Becca Royal-Gordon: Hi, I’m Becca from the Swift Compiler team. I’m going to be talking today about how you can implement DSLs in Swift. If you’ve never heard that term, a DSL is a domain-specific language, and even if the name is new to you, you’ve probably used one before. I’m going to start by explaining what DSLs actually are and what they look like in Swift. Then I’ll explain how result builders work. They’re one of the main features used to implement Swift DSLs. After that, I’ll walk you through designing a simple DSL for part of our sample app, Fruta. And finally, I’ll show you how to write the implementation that’s in the Fruta sample code. But let’s start by explaining that acronym a little better. A DSL is a sort of miniature programming language that’s designed for programs that work in a specific area called a "domain." Because the language is designed with a particular kind of work in mind, it can have special features which make that kind of work easier to do. So when you write code for a DSL instead of a general-purpose language, you only have to write the things that are specific to your exact problem. Many DSLs are declarative. That is, you’re not really writing precise instructions to solve the problem; it’s more like you’re describing the problem in the language, and then it goes and solves it for you. The traditional way you would do this is called a "standalone DSL." You would design the entire language from scratch and write an interpreter or compiler for it. Embedded DSLs are a more modern alternative. In an embedded DSL, you use the built-in features of a host language like Swift to add the DSL’s implicit behavior to some parts of your code, effectively modifying the host language into one tailored for your domain. This is obviously way easier than designing the entire language and writing a compiler for it because you’re starting from an existing language that has already decided the basics of the syntax and already has a compiler. It also makes it easier to mix the DSL code together with non-DSL code. Often you want to use a DSL to solve a problem that’s just one part of a much larger app. If you’re writing a standalone DSL, you have to design a way to call from one language to the other. With an embedded DSL, the parts written in the DSL look like normal code to the rest of the app, so you have a much easier time interoperating. An embedded DSL can also use the tools designed for the host language. You already have debuggers and editors for Swift, and those work just fine for a Swift DSL; if you wanted those for a standalone DSL, you’d have to write your own. And because you’re starting from the host language, clients who already know that language have a lot less to learn. They already know how to declare a variable or whether there’s a space in "else if"; all they need to learn is how you’ve customized the language. Swift is designed to support embedded DSLs. And in fact, if you’ve used SwiftUI, you’ve already used one. The SwiftUI view DSL assumes you want to describe the layout of views on a device’s screen. So when you’re writing in the SwiftUI DSL, your custom code simply creates the views, and the DSL is responsible for building a tree out of them for SwiftUI to process. To understand the value of a DSL, think about what SwiftUI might be like if you wrote views in ordinary Swift instead. You’d have to create views, modify them, add them to other views, and return one at the end. You’d be creating temporary variables all over the place to hold individual views, and the result wouldn’t really convey the way the views are nested in the same way the DSL does. You’d write more code but it would convey less meaning. The SwiftUI DSL, by contrast, makes all of those tedious details implicit. It’s your job to describe the views; it’s the DSL’s job to collect the views you’re describing and figure out how to show them. But DSLs take extra effort to implement, and they take extra effort to use. So when do you want to create one? Well, there are no hard-and-fast rules, but here are some signs that you might want to use one. Look for places where the mechanics of using vanilla Swift obscure the meaning of the code. Where you spend half your time rearranging commas and square brackets and parentheses every time you change something, or have to append things to temporary arrays to accommodate the control flow. Look for situations where the best approach is to describe something to another part of your code instead of directly writing instructions about what to do with it. Like, in a server-side web framework, there might be an area that registers handlers for the URLs it supports. Instead of calling an add handler method over and over, you could design a DSL for clients to declare each URL and its handler, and then your framework can register them automatically. Look for parts of your code that will be maintained by people whose primary job isn’t programming. Like, imagine you’re writing a text adventure game; only a few developers will work on most of the code, but the map of rooms will be updated by game designers, and NPC dialog will be added by writers. Maybe a DSL would make their jobs easier. And look for situations where you’ll get a lot of mileage out of the DSL. Libraries are a good example because they get used by many different clients. But a good DSL could also handle something in your project that you define a lot, or even just something you have to read or update frequently and you want to make that as easy as possible. Whatever reason you see to create a DSL, you need to balance it against the fact that it will not only take some effort for you to design and implement, it will also take some effort for its clients to learn. If methods and array literals are nearly as good as a DSL, they’re often the right answer, because Swift programmers will already know exactly how to use them. But sometimes, like in SwiftUI, a DSL is the right answer. So how do you make one? Well, let’s break down how Swift features are used together to build the SwiftUI DSL. In addition to Swift’s generally clean syntax, the SwiftUI DSL takes advantage of four things. Property wrappers. These let clients declare variables that are tied to DSL behavior. Trailing closure arguments. These let the DSL provide functions or initializers that read almost like custom syntax that’s been added to the language. Result builders. These collect the values computed in your DSL’s code into a return value so you can process them. And finally, modifier-style methods. These are basically just methods that return a wrapped or modified version of the value they’re called on; since result builders collect the values computed by your code, this pattern works really well with them. Now, property wrappers were already covered in the last half of this session from 2019, so I’m not going to talk much about them today. But these other three, and especially result builders, are going to be major topics of this session. Trailing closures and modifier-style methods are things a lot of Swift programmers are familiar with, but result builders are a more behind-the-scenes feature. So let’s talk about how they work so we can start building DSLs with them. Result builders are used to gather up the values created in your DSL and stitch them together into whatever data structure your language wants them in. They’re a bit like property wrappers in that you declare a special type and then you can use that type as an attribute. Specifically, you can apply a result builder to pretty much any function body with a return value, like a function or method, the getter of a computed property, or a closure. When you apply a result builder to a function body, Swift inserts various calls to static methods on the result builder. These end up capturing the results of statements that would otherwise have been discarded. So where Swift would normally ignore a return value, it instead gets passed to the result builder. These calls ultimately compute a value which is returned from the function body. So when you call the function, it executes all of the statements in that function normally, gathers up the values produced by them, and combines them into a single value that becomes the closure’s result. Result builders are a compile-time feature, so they work on any OS that your app will run on. The final version of the feature, from open-source Swift Evolution proposal 289, was included in Swift 5.4, so it shipped in Xcode 12.5 back in April. But prototypes of the feature were available before that, so you might see some older tutorials or libraries that use the prototype. Those will say "function builder" instead of "result builder," and they might not quite match the final feature. So I pointed out the features used in the SwiftUI DSL before, but now let’s talk about how they work. I’ll be simplifying a few details -- like removing irrelevant parts of some SwiftUI types and showing some fake variable names like v0 for variables generated by the compiler -- but this should help you understand the basics. The first thing to realize is that, at the top level, this VStack thing with a block that looks like new syntax is actually a trailing closure argument. If we look up what VStack is, we find that it’s a struct in SwiftUI. So the trailing closure argument gets passed to this initializer on that struct. Now, when we look at the parameter the closure gets passed to, we notice that it has a ViewBuilder attribute on it. That attribute tells the compiler that it should apply the result builder named ViewBuilder to the closure. But what is ViewBuilder? Well, we look for a type by that name, and we find this type, again in SwiftUI. Note how it has an @resultBuilder attribute on it to tell the compiler that it’s a result builder. Now that Swift has located the result builder type, it starts applying it to the closure. The first thing it does is create variables for all of the statements that produce results. Once it’s created these variables, it writes a call to the buildBlock method in ViewBuilder and passes all of those variables to it. buildBlock’s job is to process or combine all of its parameters into a single value, which it returns. Then the compiler writes a return statement that returns buildBlock’s result from the closure. So basically, the compiler took your code and added the code in yellow so that the ViewBuilder could assemble all of the values you created into a single value that VStack will use as its content. Now, I’d like to point out how modifier-style methods fit into this. A modifier-style method returns either a modified copy of self or a copy of self wrapped in a different type that adds new behavior. And it does this within the same statement that created self in the first place. So it ends up changing the value before the result builder sees it. And you can call other modifier-style methods on that method’s result, so you can apply several changes and compose them together, all before the result builder ever sees the value. Those two things -- the ability to compose modifiers and the fact that they modify the value before the result builder sees it -- are why Swift DSLs often use modifiers. The two just work nicely together. Now, one thing we were worried about when we designed result builders was that, if they let you change Swift’s behavior too radically, clients wouldn’t be able to trust that anything in the DSL worked like normal Swift code. So when we designed result builders, we tried to strike a balance between having enough power to make a useful DSL and making sure that Swift’s features still work as clients expect them to. Result builders don’t radically reinterpret the code a client writes. Statements still end with newlines, calls still use parentheses, curly brackets still have to match up; all of the basics of Swift syntax work exactly the way your clients expect. They also don’t introduce new names that wouldn’t be visible from normal code written in the same place. There are some language features that don’t make much sense when you’re using a result builder -- mostly things like catch or break that interrupt control flow in ways that don’t really fit well into the idea of capturing and using statement results. Those features are disabled when you’re using a result builder. And there are some features -- like if, switch, and for-in statements -- that are disabled unless your result builder provides extra methods that are used to implement them. But if Swift allows you to use a keyword, it will work the way it normally does. You won’t end up with, like, if-else statements that run both the true and false blocks, or loops that skip some of the elements. Result builders just capture the statement results that would otherwise have been thrown away, nothing more. So clients can count on them, you know, making sense.
OK. So now that we have an idea of what result builders are and how they work, we can start designing a DSL that will use them. If you’ve never worked on a language before, you might find that thought intimidating, but designing a Swift DSL is actually a lot like designing a Swift API. Like a Swift API, a Swift DSL is not starting from scratch; it uses Swift’s syntax and capabilities to express ideas and behavior relevant to the problem you’re trying to solve. A DSL is just using additional capabilities that an API usually wouldn’t. Like a Swift API, a Swift DSL could be designed in several different ways that would all solve the problem, so your job is to think of alternatives and select the one you think is best; a DSL just has a much larger space of potential solutions. And like a Swift API, a Swift DSL’s best rule of thumb is usually to choose the design that results in the clearest use sites. A DSL just assumes that clients will invest a little time up front learning the language, so it puts less priority on being crystal clear to people who’ve never seen it before. So if you’ve designed APIs before, you’ve got a good starting point to design DSLs. And for that matter, some of the suggestions and techniques I’ll be using for DSLs transfer really well to API design. In this talk, we’ll be designing a DSL for the app Fruta. You’ll find a working implementation of this in the Fruta sample code. Fruta includes 15 smoothie recipes in its source code, and before the DSL, we simply created each smoothie by calling the memberwise initializer and assigning it to a static constant. And then we stored an array of all the smoothies into another static constant, and depending on whether the particular view wants to include recipes that require an in-app purchase, we either return the entire list or filter out the paid ones. Now, this is perfectly shippable and you could stick with it if you wanted to. But the smoothie recipes get updated pretty often and, unlike the rest of the app, they’re updated by designers and marketing folks and managers, so we might want a DSL to make that a little less complicated. And looking at the way we’re doing it now, I can’t help but notice a few drawbacks. The need to filter the paid smoothies out of the list has contorted this code. allSmoothies and hasFreeRecipe are only ever used in this function; they otherwise don’t need to exist. Yet if you try to imagine implementing this without them, you can see why we didn’t do that. The mechanics of creating the array and appending elements to it start to obscure the actual list of smoothies, which is sort of the point of this function. Similarly, the fact that the list of smoothies is separate from the smoothie definitions is a little silly. A few of these constants are used by previews, but most of them appear only in this list. And because you define a smoothie in one place and add it to the list in another, it creates an opportunity for mistakes. What if you declare a new smoothie constant but forget to add it to the list? Or what if you add a smoothie twice? If we look back at the definitions of individual smoothies, I also see two other things that bother me. One is that the ingredient list is just incredibly wordy. Like, each entry repeats some version of the word "measure" three times. In this line, the actual information we care about is 1.5 cups of oranges. The rest of the line isn’t saying anything useful; it’s just visual clutter. There’s inevitably going to be some supporting syntax around the information that matters, but when there’s this much, the boilerplate around it just overwhelms the information we’re trying to convey. The other thing I notice is the sheer number of lines devoted to each smoothie compared to the amount of information that’s actually present. I think the culprit here is the difference in the length of arguments. Some of these arguments are very short and could fit together on a single line. Others are longer and really need a line of their own. Now, you could combine the short arguments onto a single line and then use separate lines for the long ones, but most style guides frown on that. We’d like a syntax where it’d be natural to style them differently. Put these altogether and we’ve got a bunch of goals we want our DSL to accomplish to make it easier to maintain the smoothie list. Now, the next thing to do is to look at various ways we could design the DSL to achieve these goals. There are a ton of different designs that would address each of these points. I’m going to quickly explain what I decided to do for the first three goals, and then we can explore the last goal in more detail. I decided we’d define the smoothie list in the all method. The smoothies will be defined directly in the body without using static variables, so we don’t have to worry about someone defining a smoothie and forgetting to list it. We’ll use a result builder called "SmoothieArrayBuilder" to activate our DSL and collect the smoothies into an array; that way we don’t need to use an array literal or collect things into a temporary variable. And we’ll allow smoothies to be put in if statements so we don’t need to filter the list like we did before. This is great because clients who already know Swift will know how an if statement is used, and clients who don’t will probably understand "if includingPaid" with little trouble. I decided to specify the ingredient amounts by using modifier-style methods. Ingredient will have a method called, "measured(with: )" which takes a unit and returns a measured ingredient with one of that unit. If you want a different amount of that unit, the scaled(by: ) modifier on a measured ingredient returns it with the quantity multiplied by the number you pass. So one cup of oranges becomes 1.5 cups of oranges, and one cup of avocado becomes 0.2 cups of avocado. Now, why is scaled(by: ) a separate modifier? One of the screens in Fruta has a control that can be used to scale the ingredient quantities in a smoothie recipe. We previously passed the multiplier down to each ingredient row, which multiplied its quantity. But I realized that I could actually use the scaled(by: ) modifier to instead scale the ingredients before they’re passed to the rows, which let me simplify the row views. So by tweaking my smoothie DSL design a bit, I was able to reuse a piece of it in another part of the project. So with the changes to achieve our first three goals, we’re starting to see our new DSL take shape. Now let’s focus in on that last goal: redesigning the individual smoothie entries so they’re more compact, and hopefully so there’s also less confusing punctuation our clients could trip over. Let’s take a look at a few different ways we could arrange this information to help with that. One thing we could do is use modifier-style methods to add the description and ingredients. This would work, but it’s kind of wordy and it’d be easy for someone to forget the description or specify it twice or something. Another thing we could do is give each field a marker type and put them in a result builder closure. But this puts the ID and title on their own lines, which we’re trying to avoid. So maybe we could move the ID and title back into the parameter list, and use marker types for the other two fields. But I feel like this is still maybe a little more ceremony than we really need. I realized that when I looked at the user interface for recipes. They’re always presented in a specific order: title at the top, description in the middle, list of ingredients at the bottom. And we don’t bother labeling the title or description. We let their visual hierarchy -- the fact that the title is presented more prominently than the description -- do the talking. So I drew some inspiration from that and decided that our smoothie DSL should do the same. It puts the title at the top, the description in the middle, and the list of ingredients at the bottom. And it lets the fact that the description is below and more indented than the title -- so it’s less visually prominent -- convey the meaning of the description string so there’s no need to label it. I think the result is immediately understandable without unnecessary complications. And when we put it into the context of the DSL as a whole, I think we have a pretty comfortable fit. But you might disagree, and that’s OK. DSLs are programming languages, and personal taste and subjective trade-offs are a big part of designing any programming language. That doesn’t mean you shouldn’t be rigorous. You should start with a clear idea of what you want from the language. You should look into whether there’s an existing solution -- like the if statement -- that can solve your problem because if you can adopt a familiar solution, people won’t have to learn a new one. You should think about how each part of the language interacts with the rest of it. In a Swift DSL, that also means thinking about how your DSL interacts with the ordinary Swift code around it, like when I picked the scaled-by modifier because I could use it somewhere else. You should look for solutions that will make mistakes either detectable at compile time or totally impossible to write. If you recall, that’s why we didn’t make description a modifier; you could have left it out by accident. With all of that in mind, you should come up with several different possibilities. Imagine how each of them would be used, write little mock-ups; you know, weigh them against each other. But in the end, you usually won’t find one that’s obviously right where the others are obviously wrong. All you can do is pick something you feel will be best for your language’s clients. If you’re not sure which is best, you should probably favor whichever is most readable. And if you’re still not sure after that... Well, personally, I like taking bolder options. I’d rather try something and walk it back if it doesn’t work than never try it and be left wondering. Now that we’ve decided what our DSL will look like, let’s go ahead and add it to Fruta. I’ve replaced the previous smoothie definitions with the final all method that uses our DSL, but I haven’t actually implemented the DSL yet. So unsurprisingly, we’ve got a ton of errors. But that’s OK. As we work through this, I’ll let these errors guide me to the problems I need to solve, and by the end we’ll have something that builds without any errors. So let’s just start at the top of the function with this first error: "Unknown attribute ‘SmoothieArrayBuilder’." The result builder doesn’t actually exist yet, so of course that's not going to work. Let’s go fix that. I’ll start by making a type called "SmoothieArrayBuilder" that’s marked with the result builder attribute. Now, Swift will never actually make an instance of this type; it’s just a container for a bunch of static methods. So I’ve made it an enum and I won’t define any cases. It’s impossible to create an instance of an enum which has no cases, so this keeps people from using it incorrectly. If I build just this, I’ll get an error saying that the result builder needs a buildBlock(_:) method. It has a fix-it which will insert one, so I’ll accept that fix-it and then we’ll figure out how to implement it. Now, if you recall from earlier, the way buildBlock(_:) works is that if you have code like this with a bunch of individual statements, each of those statements gets assigned to a variable, the variables are all passed to buildBlock(_:), and the value returned by buildBlock(_:) gets returned by the closure. So it stands to reason that our buildBlock(_:) method needs to accept a bunch of smoothies as parameters and return an array of smoothies. If we implement that using a variadic parameter so that any number of smoothies can be passed to the method... ...and build... ...well, what we get is a bit better. There are still plenty of errors, but the one saying smoothie array builder was an invalid attribute is gone and the attribute even changed color to indicate that it’s a known type. So let’s move on to the next errors, the ones for the smoothie initializer. One says that we’re passing a trailing closure to a string parameter. The other says we’re missing the measuredIngredients argument. So clearly we’re using the old initializer, which expects the description and ingredients as parameters. We’ll need to make a new one. So let’s implement that initializer with an ID, a title, and a trailing closure that returns the description and ingredients. I’m going to tell you right now, we’ll have to come back to this initializer later. If I build right now, it does clear away all of the errors from the smoothie initializers, so you might think this is working perfectly. But that’s actually a little misleading. You see, there’s another error down here on the if statement that’s caused by the smoothie array builder not being finished. And because that error is there, Swift isn’t checking the insides of the closures yet. Like, if I go into this closure and I just write some random variable name that I know doesn’t exist, and then build it, Swift doesn’t flag an error. What’s happening is that Swift sees that the result builder didn’t apply correctly, so it doesn’t really trust that any errors it found in these closures would actually be accurate. So it’s just not looking for errors there yet. Later on, when we finish the smoothie array builder, we’ll suddenly start seeing those errors and we can fix them at that point. But for now it’s easier to keep working on the smoothie array builder, so let’s set those closures aside and move on to the next error. If we look at this error, Swift tells us that we can’t use an if statement with smoothie array builder, but there’s a method we can add to support it. If statements are one of a few Swift features like this; they are disabled unless your result builder implements extra methods to support them. So to get started implementing this, let’s hit the fix-it here and see what it adds. So apparently we need to implement a method called buildOptional(_:), which takes an optional array of smoothies and returns an array of smoothies. So how does this method get used? Well, take this simplified example of the all method, which has an if statement with no else. Like our previous example without the if statement, this is going to capture the result of each statement into a variable, pass those variables to buildBlock(_:), and return the result of buildBlock(_:) from the closure. The only question is, how is it going to capture the result of the if statement? Well, the first thing it’s going to do is capture all of the statements in the if statement’s body into variables, and then combine those variables together using buildBlock(_:), just like it does at the top level. But this is where buildOptional(_:) comes in. Instead of returning the result of that inner buildBlock(_:) call, Swift will pass it to buildOptional(_:), and the value returned by buildOptional(_:) becomes the value of the if statement as a whole. But this leaves the variable uninitialized if the if condition is false. That’s why buildOptional’s parameter is an optional array of smoothies. Swift will add an else branch which sets the value of the if statement’s result to the return value from buildOptional(nil). For SmoothieArrayBuilder, the upshot of this is that we want buildOptional(_:) to either return the array that was passed to it from buildBlock(_:) or return an empty array if the parameter is nil. If we build it now, we get... ...a really weird-looking error. Can’t pass array of type smoothie as variadic arguments? What? Well, let’s go back to our generated code. The if statement ends up producing an array of smoothies. But actually, buildBlock(_:) doesn’t want arrays of smoothies; it wants single smoothies. We’ll need to change that. So maybe we can make buildBlock(_:) take arrays of smoothies as its arguments, and then use flatMap(_:) to concatenate those many arrays of smoothies together into a single array of smoothies. Great! Build that, and... ...no. Now our if statement works, but all the smoothie lines broke. We kind of need those. Cannot convert value of type smoothie to array of smoothie. What happened? Well, we changed buildBlock(_:) so that it matches the array of smoothies returned by buildOptional(_:). But we forgot that it also needs to match the individual smoothies returned by the normal statements. Oops. Basically, if you’re going to allow any sophisticated control flow, buildBlock’s return type needs to be something that can be passed as a parameter to buildBlock(_:). There are two ways you can accomplish this. One way is to make sure that buildBlock(_:) and the other result builder methods return types compatible with the statements allowed in the result builder. For example, this is how SwiftUI’s ViewBuilder works. In the SwiftUI DSL, everything conforms to the View protocol, including the types returned by buildBlock(_:) and other view builder methods. But that’s not a great fit for our smoothie DSL because unlike SwiftUI views, you don’t nest smoothies inside of other smoothies. The other thing you can do is have the result builder convert the values of normal statements into the same type returned by buildBlock(_:). That’s a better fit for this DSL. We can do that by adding a method called buildExpression(_:). When we add a buildExpression(_:) method, Swift passes each bare expression to that method before it captures it into a variable. That will give us an opportunity to convert those into arrays. But values that come from other result builder methods, like the ones produced by buildOptional(_:) and buildBlock(_:), don’t get wrapped in these calls, so they won’t have this conversion applied to them -- which is good because they’re already returning arrays. So what we’re going to do is implement a buildExpression(_:) method. Xcode’s code completion knows all about result-builder methods, so we can ask it to write the signature for one. Then we change the parameter type to Smoothie and just return the expression parameter wrapped in an array literal. So now our single smoothies will be turned into the arrays of smoothies that buildBlock(_:) needs. Build that, and... ...fantastic! Our if statement works and so do the smoothie initializers. However, if we look in the minimap, we’ll see that there’s a second if statement down here that doesn’t work. That’s because this one has an else clause. buildOptional(_:) actually only works for plain if statements. If you have an else statement, or an else-if, or a switch, you need to implement a pair of methods, called buildEither(first:) and buildEither(second:). Let’s create these using the fix-it and then talk about how they work. So let’s look at this simplified example with an if-else statement. Most of the transform is just like the one for buildOptional(_:). Like buildOptional(_:), the whole if-else statement will end up filling a single variable. And also like buildOptional(_:), each of the blocks in the if statement will have the statements inside it captured into variables and then buildBlock will be used to combine them together into one value. What’s different from a plain if statement is that instead of using buildOptional(_:) to generate the final value, we use one of the buildEither methods. If there are two branches, like an if and an else, then the first one uses buildEither(first:) and the second uses buildEither(second:). That allows result builders which care about which branch you took to distinguish between them. Now, if you’re wondering what we do if there are three or more cases, the answer to that is actually pretty cool. We build a balanced binary tree with each branch as one of the leaves, and then we treat the nonleaf nodes as calls we need to make, with the edge telling us whether to use buildEither(first:) or buildEither(second:). We note which sequence of calls each branch should use, then we generate code that assigns to the variable with that sequence of calls. So even though we only have two methods, the result builder can still distinguish between the three branches. Not too bad. Anyway, now that we know how the buildEither methods work, we can go ahead and write them. And since SmoothieArrayBuilder doesn’t actually care which branch you took, there’s not much we need to do; just return the array argument. So now we build this thing, and... it still doesn’t quite work. But we’re close! You might remember this kind of error from when we had the array-of-smoothie problem, only now, it’s not complaining about type Smoothie, it’s type '()'. That’s an empty tuple, the type you probably think of as Void. If we think about the generated code, it makes sense why this is a problem. We’re calling buildExpression(_:), but the expression that's being passed is calling logger.log, which returns Void, not Smoothie. So we’ll write an overload of buildExpression(_:) which takes a Void parameter and returns an empty array. Then we rebuild, and now the log call works correctly! We do have a zillion more errors, but this is actually good news. See, the first of these errors is from that fake variable I added way back at the beginning to show you that Swift wasn’t finding errors in the trailing closures. Now it is, which means that we’ve finished the smoothie array builder! So yay, errors! Let’s delete that fake variable and look at what’s left. If we look closely, we’ll find that there’s less to do here than it looks like. All of the description lines have the same warning, and all of the ingredient lines have the same two errors. So even though we have, like, a hundred errors and a dozen warnings, this is actually just the same couple of problems happening over and over. Let’s take a closer look at the errors on one of these ingredient lines. The compiler has two complaints: it can’t figure out what type to look for cups in, and it doesn’t think Ingredient has a member called "measured." Well, that makes sense; we haven’t implemented the measured(with:) or scaled(by:) modifiers, so it can’t find anything called "measured." And it has no idea what cups are because it doesn’t know that measured(with:) is supposed to take a volume unit. So let’s pop over to MeasuredIngredient.swift and implement these two modifiers. measured(with:) goes on an Ingredient and returns a measured ingredient with one of the unit the caller passed. And scaled(by:) goes on a measured ingredient and returns a new measured ingredient with the measurement multiplied by the scale the caller passed. Pop back over to Smoothie.swift and build... ...and, OK. We’re seeing a lot more warnings and only a few errors. And if we look closely, we’re seeing only one kind of warning -- telling us that each expression in the closures is being ignored -- and one kind of error -- telling us that the closures don’t have return statements. To understand why, let’s talk about these trailing closures and how result builders interact with them. In this example, the SmoothieArrayBuilder is going to affect the outer statements just the way we’ve seen before. They get passed to buildExpression(_:), saved to variables, and the variables get passed to buildBlock. But what about these closures? What will the result builder do to them? Well, it does... ...absolutely nothing because the closures are actually separate functions nested inside the function we’ve applied the result builder to. Result builders only apply to one function; they don’t affect the functions or closures nested inside it. If you want them to be affected by the result builder, then you have to apply it to them in some other way. There are three ways to apply a result builder to a function body. The first is to write the attribute directly on the function or property, like we did for SmoothieArrayBuilder. The second way to apply a result builder is to write it on a function or property requirement in a protocol; then it will be automatically applied to the implementations on all conforming types. That’s how the body property in SwiftUI Views works: the ViewBuilder attribute is applied to the body requirement in View, so it’s automatically applied to any View’s body property too. The third way to apply a result builder is to write it before a closure parameter. If you do that, Swift will then infer that any closure passed to that parameter should have the result builder applied to it. If Swift has inferred a result builder from a protocol or parameter and you don’t actually want it to be applied, you can disable it by explicitly returning a value using a return statement. But in this case, since we’re using a closure, we want the last of those three options: infer the result builder from the closure parameter. We do that by writing an attribute before the argument label. Now, we could write SmoothieArrayBuilder here, but that’s probably not the best way to do this. SmoothieArrayBuilder produces an array of smoothies, but we don’t want this closure to produce smoothies; we want it to produce a string and an array of ingredients. And we don’t need if statements or Void-returning calls in this closure, either. So really, we’re applying a separate set of language rules to this closure, and rather than mixing that second set of rules into SmoothieArrayBuilder, it makes more sense to create a new result builder that implements these new rules. Let’s call it SmoothieBuilder and create a new type for it, and start writing a buildBlock(_:) method. Now, this one is a little special. We want to accept any number of measured ingredients, but we also want to take a string at the front. So how are we going to do that? Well, if you think about how SmoothieBuilder -- which, remember, is a simple result builder with only a buildBlock method -- is going to be expanded out, each of those lines is going to be passed as a different parameter. So it seems like maybe you could just write a string parameter at the beginning of buildBlock, and then the first statement would have to produce a string instead of a MeasuredIngredient. So let’s try doing that. Add a string parameter up front, and have it return a tuple of a string and an array of ingredients. And if we build... Hey, look at that! Zero errors! Our DSL works! Now, result builders support a couple more features, like for-in loops and processing the final return result. If you want to use those, they’re described in the Swift Programming Language book. But before we finish, I want to call attention to one of the most important parts of language design: good error messages. One of the things you learn when you’re designing a language is that there are many more ways to write invalid code than valid code, so you should spend some time thinking about the errors you’ll emit for invalid code. Your behavior when the code is wrong is just as important as when the code is right. Now, for a Swift DSL, you’ll get Swift’s error handling for free. But the error messages clients will get are designed for general Swift code. They aren’t phrased in terms of your language’s rules, so they may not communicate the problem clearly to your clients. For example, imagine someone forgot to put a description in one of these smoothies. Swift is going to emit an error message, but it’s a little unclear. It complains that the first ingredient can’t be converted to a string. So how does this code end up producing this error? Well, the Swift compiler doesn’t really understand the semantics of our Smoothie DSL, it only understands the semantics of the Swift code generated to use the result builder. So when it tries to diagnose this error, it doesn’t think of this value as the description of the Smoothie or the first ingredient. It thinks of it as the first argument to buildBlock. v0, the first argument to buildBlock (_:), is a MeasuredIngredient, but it’s being passed to a string parameter. So Swift thinks of this error as, "You’re trying to pass a MeasuredIngredient to a string parameter, but I can’t convert MeasuredIngredient to a string." The error message is not technically wrong but it’s not really helpful, either. Compiler engineers have a trick for this: we make the compiler support the invalid thing but produce an error when you do it. For example, there’s a slot in Swift’s function grammar where you can write throws, rethrows, or nothing. If you write some other unsupported word, the compiler guesses that it was supposed to be part of a different statement and gives you an error telling you to either add a semicolon or use a new line. But, if you write "try" specifically, you get a different error. The compiler suggests replacing it with throws and then parses the rest of the file as though you’d written throws there instead. This is a special case we added to the Swift parser. We noticed that developers sometimes type other error-handling keywords here when they mean to write throws, so we made a tiny, undocumented extension to the formal grammar of the language. We parse those mistaken keywords here and then diagnose a different error than usual, tailored for that specific mistake. I point this out because you can do something similar in a result builder to improve its error behavior. Specifically, if you make an overload of a result-builder method which matches the bad code, and then you mark that overload as unavailable, you can specify an error message to use when diagnosing it. And so instead of getting a generic error that might not convey the problem very well, clients will get a more specific error message tailored to that mistake. What we’ll do is copy buildBlock(_:), delete the description parameter so we’re matching blocks with only an ingredient list in them, and replace the body with fatalError() so we don’t have to fake a return value. The method will never be called successfully, so the body just has to be something that’s valid. Then we’ll mark this overload as unavailable and give it a message describing the problem more clearly. This unavailable annotation means that the method can’t actually be used. If you write a call to it, that’s an error. So now, if I pop back up to the top and rebuild, I see that I get a much, much clearer description of what’s wrong. Instead of saying that the first ingredient should be a string, it says that the description string is missing. So the client doesn’t start thinking that the ingredient is wrong or have to wonder what the string is for; the error tells them that right up front. That’s a much better experience. And the most important thing to remember about implementing a DSL is that it’s all about improving the client’s experience. A DSL can make some very complex, repetitive code much cleaner by allowing clients to define things without worrying about the mechanics of assembling the definition. Result builders are a powerful tool that allow your DSL to collect the values being defined. And modifier-style methods give you a composable way to change those values before the result builder captures them. But remember that if you write a DSL, clients will have to learn how to use it. Provide a DSL only when it’s going to be worth their time and effort. So thanks for your time, and enjoy building some little languages. ♪
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.