Skip to content

zsmb13/VillageDSL

Repository files navigation

Village DSL Build Status

This repository contains samples of various Kotlin DSL designs. It consists of two main exercises: a simple and an advanced model, and it offers various DSL solutions for constructing structures within these models. Both models aim to simulate a fantasy game of some sorts where you have to describe a village and its contents.

The simple model

The simple exercise uses just three model objects: the village, the houses it contains, and the people who are in those houses. Here are the data classes representing these concepts:

data class Person(val name: String, val age: Int)
data class House(val people: List<Person>)
data class Village(val houses: List<House>)

Note that the examples included are larger than the code samples you see here below, look at the linked source files for the full examples.

Approaches without DSLs

First, let's see how we can construct a hierarchy of these models without defining a DSL.

This is essentially the approach that we'd take if we had to use Java, and it's translated to Kotlin syntax. We create mutable lists for everything, add items to them one by one on separate lines, and then create the objects that contain these lists.

val houses = mutableListOf<House>()

val people1 = mutableListOf<Person>()
people1.add(Person("Emily", 31))
people1.add(Person("Hannah", 27))
people1.add(Person("Alex", 21))
people1.add(Person("Daniel", 17))

val house1 = House(people1)
houses.add(house1)

val village = Village(houses)

This has all the usual pain points that constructing a hierarchy in Java entails: we can't really see the hierarchy itself in the code, and we have to follow a weird, unnaturally twisted structure with our code because of the limitations of the API. More importantly, this style of code gets complicated to read and modify quite quickly.

We can improve on this by quite a bit by just nesting some of these calls and using the factory methods for collections provided by the Kotlin standard library.

val house1 = House(listOf(
        Person("Emily", 31),
        Person("Hannah", 27),
        Person("Alex", 21),
        Person("Daniel", 17)))
        
val village = Village(listOf(house1))

The code we have here is easier to write, read and maintain than the previous one. It still doesn't show hierarchy very well however, and it will face some of the same problems as the previous code when the described model gets larger.

This "poor man's DSL" solution is mostly included for good measure. It makes use of the previously mentioned collection factory methods, named parameters, and some formatting to create something resembling a DSL.

val village = Village(listOf(
            House(listOf(
                    Person(
                            name = "Emily",
                            age = 31
                    ),
                    Person(
                            name = "Hannah",
                            age = 27
                    ),
                    Person(
                            name = "Alex",
                            age = 21
                    ),
                    Person(
                            name = "Daniel",
                            age = 17
                    )
            ))
))

The problem here is that modifying the code is tedious compared to a real DSL, since you have to pay attention to where the listOf calls happen, and you can't just move around pieces of the code without having to check that all your commas are in the right place.

DSL approaches

Below are various DSL approaches. Neither of these are supposed to be the solution to the posed exercise, they are just various examples of DSLs you can use to solve the problem. This document only contains small samples of how to use these DSLs, check the linked packages for the implementations and full examples.

To start, here's the DSL that follows the conventions most often used by DSL authors: it makes use of function literals with receivers that put you into the scope of builders that have the appropriate properties, as well as default and named arguments.

val v = village {
    house {
        person {
            name = "Emily"
            age = 31
        }
        person(name = "Hannah") {
            age = 27
        }
        person("Alex", 21)
        person(age = 17, name = "Daniel")
    }
}

Note that while the code here showcases a variety of different ways for calling the same person function, sticking to one of these styles at a time is of course recommended.

You can see that even this simplest DSL gets rid of the modification woes of the non-DSL approaches, as the blocks here can be moved around freely and easily.

Instead of doing the same thing over and over on every level of the hierarchy as before, we can construct some of our models (usually the leafs of the hierarchy) in a more direct way by just calling their constructors, and adding them to the right parent using overloaded operators. (A good example of this is how text can be appended to elements in the kotlinx.html library.)

val v = village {
    house {
        +Person("Emily", 31)
        +Person("Hannah", 27)
        +Person("Alex", 21)
        +Person("Daniel", 17)
    }
}

This is done by using an overloaded unaryPlus operator, that's defined as a member of the class responsible for creating a House. This way, both the list containing people that will be in the House and the constructed Person object are in scope inside the function.

The same can be done using the unaryMinus operator, as you can see in the full sample. This can provide a nice "list" look in your DSL.

Pushing the limits of the Kotlin language, using some dummy objects and infix functions can you can get pretty far with making your DSL fluent, and read almost like sentences. (A good official example is the KotlinTest library.)

val v = village containing houses {

    house with people {
        "Emily" age 31
        "Hannah" age 27
        "Alex" age 21
        "Daniel" age 17
    }
        
}

Note that while in the case of the "usual DSL" it's verified that the blocks are nested in the expected order (by the @DslMarker annotation, see in the example here), these potentially chained infix function calls can lead to code that compiles but does nothing sensible.

The advanced model

The advanced example's model extends the simple model with various types of loot that can be placed inside the houses.

data class Village(val houses: List<House>)
data class House(val people: List<Person>, val items: List<Item>)
data class Person(val name: String, val age: Int)

interface Item
data class Gold(val amount: Int) : Item

interface Weapon : Item
data class Sword(val strength: Double) : Weapon

interface Armor : Item
data class Shield(val defense: Double) : Armor  

Just to reiterate before getting into the various approaches: the code examples here are just short snippets. The full example is included in the linked source files.

Approaches without DSLs

Again, let's first take a look at how we can create instances of the above models and nest them appropriately without defining a DSL.

This approach doesn't really deserve any more explanation than it got at the simple model. It's tedious to write, hard to both read and modify.

val houses = mutableListOf<House>()

val people1 = mutableListOf<Person>()
people1.add(Person("Alice", 31))
people1.add(Person("Bob", 45))
val items1 = mutableListOf<Item>()
items1.add(Gold(500))
val house1 = House(people1, items1)
houses.add(house1)

val village = Village(houses)

Nesting these calls gets you a bit closer to a DSL, but this solution has the same issues as it had with the simple model. listOf calls are ugly, commas are easy to miss and difficult to maintain.

val village = Village(listOf(
            House(listOf(
                    Person(
                            name = "Alice",
                            age = 31
                    ),
                    Person(
                            name = "Bob",
                            age = 45
                    )
            ), listOf(
                    Gold(
                            amount = 500
                    )
            ))
))

DSL approaches

Same old, same old. Lambdas with receivers and builder classes. This is the no-thrills DSL for this problem.

val v = village {
    house {
        person {
            name = "Alice"
            age = 31
        }
        person {
            name = "Bob"
            age = 45
        }
        gold {
            amount = 500
        }
    }
}

Here's something you probably shouldn't do. I included the entire village in this sample code so that you can see more of what this solution includes. While this is a really weird and unnecessary DSL, the extensible structure of its implementation is worth looking at.

val v = village {
    house {
        "Alice" age 31
        "Bob" age 45
        500.gold
    }
    house {
        sword with strength value 24.2
        sword with strength level 16.7
        shield with defense value 15.3
    }
    house()
    house {
        "Charles" age 52
        2500.gold
        sword
        shield
    }
}

Tests

All approaches for both exercises contain tests, which check that the required structure was produced by all approaches. These tests make use of the default toString implementations of data classes, and simply assert that the output of the constructs in the main functions are the same as the expected String.

About

Examples of Kotlin DSL design

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages