EP 27 · Domain‑Specific Languages · Aug 27, 2018 ·Members

Video #27: Domain‑Specific Languages: Part 2

smart_display

Loading stream…

Video #27: Domain‑Specific Languages: Part 2

Episode: Video #27 Date: Aug 27, 2018 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep27-domain-specific-languages-part-2

Episode thumbnail

Description

We finish our introduction to DSLs by adding two new features to our toy example: support for multiple variables and support for let-bindings so that we can share subexpressions within a larger expression. With these fundamentals out of the way, we will be ready to tackle a real-world DSL soon!

Video

Cloudflare Stream video ID: 1cb19ada7d95bf6c64f8498425205096 Local file: video_27_domain-specific-languages-part-2.mp4 *(download with --video 27)*

Transcript

0:05

In the last episode we gave a defined “domain specific languages”, also known as DSLs, as any language that is highly tuned to a specific task. Some popular examples are SQL, HTML, and even Cocoapods and Carthage. We also defined an “embedded domain specific language”, also known as an EDSL, as a DSL that is embedded in an existing language. The Podfile of Cocoapods is an example of this, because the Podfile is technically written in standard Ruby code.

0:44

We then began constructing a toy EDSL example in Swift from first principles. It modeled an arithmetic expression where only integers, addition, multiplication, and variables were allowed. We defined two interpretations of this DSL: one for evaluating the expression to get an integer from it once you do all the arithmetic, and also a printer so that we could represent the expression as a string. We also experimented a bit with transforming the DSL abstractly, such as performing some basic simplification rules on an expression, such as factoring out common values.

1:20

This time we are going to add two very advanced features to our DSL: the ability to support multiple variables, and the ability to introduce let-bindings, which allows us to share expressions within our DSL. It’s kinda strange and cool. Recap Here’s all the code we wrote last time. // x * (4 + 5) // (x * 4) + 5 enum Expr { case int(Int) indirect case add(Expr1, Expr1) indirect case mult(Expr1, Expr1) case var(String) } extension Expr: ExpressibleByIntegerLiteral { init(integerLiteral value: Int) { self = .int(value) } } func eval(_ expr: Expr, with value: Int) -> Int { switch expr { case let .int(value): return value case let .add(lhs, rhs): return eval(lhs) + eval(rhs) case let .mult(lhs, rhs): return eval(lhs) * eval(rhs) case .var: return value } } func print(_ expr: Expr) -> String { switch expr { case let .add(lhs, rhs): return "(\(print(lhs)) + \(print(rhs)))" case let .int(value): return "\(value)" case let .mult(lhs, rhs): return "(\(print(lhs)) * \(print(rhs)))" case let .var: return "x" } } func simplify(_ expr: Expr) -> Expr { switch expr { case .int: return expr case let .add(.mult(a, b), .mult(c, d)) where a == c: return .mult(a, .add(b, d)) case .add: return expr case .mult: return expr case .var return expr } } We first have an Expr enum with cases describing different kinds of arithmetic expressions, including integers, addition, multiplication, and variables. We then have a few functions that interpret this expression type in various ways, including evaluation, printing, and simplification.

3:34

So let’s give a quick demo of these functions using an example expression. let expr = Expr.add(.mul(.var, 4), .mul(.var, 6))

3:46

We can evaluate the expression using the eval function by including a value to replace each variable with. eval(expr, with: 2) // 20

4:00

We can print the expression using our custom print function to convert our expression to a readable string. print(expr) // "((x * 4) + (x * 6))

4:08

Finally, we can simplify this expression using simplify to factorize out the repeated x . print(simplify(expr)) // "(x * (4 + 6))" Adding multiple variables to our DSL

4:23

One current limitation of our DSL is that it only supports a single variable, but in programming we are pretty used to having any number of variables at our disposal, not just one. How easy is it to support that in our DSL?

4:33

Should be easy enough! We just need to make a change to our Expr type. Instead of having a var case with no associated value, let’s associate a string, the name of the variable that we will bind to: enum Expr { case int(Int) indirect case add(Expr1, Expr1) indirect case mult(Expr1, Expr1) case var(String) }

4:47

Most of everything still compiles. We just need to bind variable names to our expression. let expr = Expr.add(.mul(.var("x"), 4), .mul(.var("x"), 6)) eval(expr, with: 2) // 20 print(expr) // "((x * 4) + (x * 6)) print(simplify(expr)) // "(x * (4 + 6))"

5:00

And with that change, everything compiles! And it almost looks like it all still works, but we still have some changes to make to our interpreter functions.

5:08

For evaluating an expression it is no longer enough to provide a single value for the variable, because we could have multiple. So instead, we provide a dictionary mapping all our variables to the values they hold: func eval(_ expr: Expr, with env: [String: Int]) -> Int { switch expr { case let .int(value): return value case let .add(lhs, rhs): return eval(lhs, env: env) + eval(rhs, env: env) case let .mult(lhs, rhs): return eval(lhs, env: env) * eval(rhs, env: env) case let .var(id): guard let value = env[id] else { fatalError("Couldn't find \(id) in \(env)") } return value } }

6:30

We just need to fix the call site where we invoke eval . eval(expr, with: ["x": 2]) // 20

6:38

And everything still works as it did before! We’re not using multiple variables yet, but at least we support it.

6:46

Note that this is the first time we are losing a bit of safety. let expr = Expr.add(.mul(.var("x"), 4), .mul(.var("y"), 6)) eval(expr, with: ["x": 2]) Fatal error: Couldn’t find y in [“x”: 2]

6:59

We essentially have no choice but to fatal error when we encounter a variable that is not present in the environment. If we forget to supply a variable when evaluating we will only find out at runtime, whereas the previous version we knew at compile time.

7:13

As long as all our variables are accounted for, evaluation will succeed. let expr = Expr.add(.mul(.var("x"), 4), .mul(.var("y"), 6)) eval(expr, with: ["x": 2, "y": 3]) // 26

7:25

We’ve now upgraded our DSL to support multiple variables and tweaked eval to take in a whole environment of any number of variables, which is a pretty nice feature, though we unfortunately lose a bit of the safety we previously had. There are some ways to restore that safety, though it requires some pretty advanced type-level features that Swift doesn’t have yet. It’s pretty interesting, though, to see that adding advanced features to a DSL can potentially make the DSL less safe to interpret depending on the safety of the host language.

8:13

We’ve only fixed eval so far. We still have to handle print and simplify . print(expr) // "((x * 4) + (x * 6)) print(simplify(expr)) // "((x * 4) + (x * 6)) The simplify function no longer simplifies, which is correct, since we’re handling different variables. It’s the printing that is wrong in both of these cases.

8:30

This is simple enough to solve. We just bind the variable identifier and return it. func print(_ expr: Expr) -> String { switch expr { case let .int(value): return "\(value)" case let .add(lhs, rhs): return "(\(print(lhs)) + \(print(rhs)))" case let .mult(lhs, rhs): return "\(print(lhs)) * \(print(rhs))" case let .var(id): return id } }

8:43

Everything now prints correctly. print(expr) // "((x * 4) + (y * 6)) print(simplify(expr)) // "((x * 4) + (y * 6))

8:48

And simplify can stay the same.

8:55

Let’s make variables a little nicer to use in our DSL. Just as we conformed Expr to ExpressibleByIntegerLiteral to make integer literals easier to express, let’s conform Expr to ExpressibleByStringLiteral to make variables easier to express. extension Expr: ExpressibleByStringLiteral { init(stringLiteral value: String) { self = .var(value) } }

9:32

Now we can get rid of some of that extra noise. let expr = Expr.add(.mul("x", 4), .mul("y", 6))

9:43

Much nicer. We can even shuffle some of our expressions around to make sure factorization still works. let expr = Expr.add(.mul("x", "y"), .mul("x", 6)) eval(expr, with: ["x": 2, "y": 3]) // 18 print(expr) // "((x * y) + (y * 6)) print(simplify(expr)) // "(x * (y + 6))

9:55

And it does!

10:04

That was a pretty powerful feature to add and it didn’t take a whole lot of work. Adding let bindings to our DSL

10:20

Now let’s add a very advanced feature to our DSL: let bindings. This is the ability to assign variables to expressions that can then be used later in the expression. Let’s dive in!

10:55

Swift has let bindings, so let’s take a look at some Swift code. let x = 5 * 3 // 15 x + 2 // 17

11:15

We want to build this feature into our DSL. Sounds like we need a new case in our Expr type that has associated values of the string identifier for the variable, the expression to be bound to that identifier, and then the subexpression that that binding will be available in. enum Expr { case int(Int) indirect case add(Expr, Expr) indirect case mult(Expr, Expr) case var(String) indirect case bind(String, to: Expr, in: Expr) }

11:59

A few things broke, so let’s get to fixing them. First we will fix eval . To evaluate a let-binding we must first evaluate the expression we want to bind using our current environment. We then augment the environment to add the new variable binding to it, and finally evaluate the subexpression using that new environment. Sounds complicated, but it’s actually quite simple: case let .bind(id, boundExpr, scopedExpr): let boundValue = eval(boundExpr, env: env) let newEnv = env.merging([id: boundValue], uniquingKeysWith: { $1 }) return eval(scopedExpr, env: newEnv)

13:34

It took a number of steps, but they all made sense in the end when you consider what it means to evaluate a let binding.

13:45

Printing is pretty straightforward. case let .bind(id, boundExpr, scopedExpr): return "let \(id) = \(print(boundExpr)) in \(print(scopedExpr))"

14:32

Now the simplify function needs to be updated, but we’ll just return expr for the bind case for now and leave their real implementations to the exercises. func simplify(_ expr: Expr) -> Expr { switch expr { case .int: return expr case let .add(.mult(a, b), .mult(c, d)) where a == c: return .mult(a, .add(b, d)) case .add: return expr case .mult: return expr case .var return expr case .bind: return expr } }

14:50

Let’s take this new expression for a spin. How can we use let bindings to share expressions? Expr.bind("z", to: .add("x", 2), in: .mul("z", "z"))

15:30

This is kind of complicated, but let’s assign and print it to see what’s going on. let expr1 = Expr.bind("z", to: .add("x", 2), in: .mul("z", "z")) print(expr1) // "let z = (x + 2) in (z * z)"

15:37

And it prints. Let’s try to evaluate it.

15:52

Let’s pass in an empty environment to get some insight into what we need to provide. eval(expr1, with: [:]) Fatal error: Couldn’t find x in [:] We introduced a “z” with an unbound “x”, so only “x” needs to be provided. We don’t have to provide a “z” because it’s being defined from within the expression. eval(expr1, with: ["x": 2]) // 16 That looks right!

16:45

Let’s make things a bit more complicated by multiplying the result of our binding with another expression. let expr1 = Expr.mul(3, .bind("z", to: .add("x", 2), in: .mul("z", "z")) print(expr1) // "(3 * let z = (x + 2) in (z * z))" eval(expr1, with: ["x": 2]) // 48

16:54

Evaluation looks good, but the printing is a bit tough to read. Let’s parenthesize that case. case let .bind(id, boundExpr, scopedExpr): return "(let \(id) = \(print(boundExpr)) in \(print(scopedExpr))))"

17:16

Now the printing is a bit easier to follow. print(expr1) // "(3 * (let z = (x + 2) in (z * z)))"

17:25

This is really powerful. Notice that we are binding an expression to z a single time, and then reusing that expression multiple times in the body of the binding. That means this is more efficient since we only need to evaluate z a single time. What’s the point?

18:03

We’ve done some cool stuff today, but it’s time to ask “what’s the point?” Are DSLs worth knowing about and worth studying?

18:12

Absolutely! It’s a very powerful idea. We introduced the concept of a DSL and looked at a little domain: arithmetic. We described the units of an arithmetic expression and directly translated that to an enum and came up with some interpreters. We started layering on more and more complicated features and what we came up with was a little ad hoc programming language written in Swift! We have variables, arithmetic, integer literals, and let bindings. Our study of DSLs has led us to kind of accidentally write a tiny programming language.

19:03

We think that there are a lot of problems that we encounter in everyday programming that can be recast into the language of DSLs. You have the DSL that models the problem you’re dealing with, and you have the interpreters (like eval and print ) that does the work, like executing side effects or performing a tough computation. This very clear separation has the very clear benefit of allowing us to work with DSL value types in completely pure, easy-to-test ways!

19:38

And we deal with DSLs all the time! As we saw at the beginning of the first episode, SQL, HTML, and even CocoaPods and Carthage are all DSLs. Any time you work with a DSL, it’s worth taking the opportunity to ask what it would look like to work with it as an EDSL by embedding it in, for example, Swift.

20:03

We’ll be exploring some of these real-world embeddings in the near future! Downloads Sample code 0027-edsls-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .