Use Inner Classes to Share State Between Contexts

On one hand, we want to maintain multiple contexts so that we can control the sequence of function calls that should be permitted by the DSL. On the other hand, the function calls, executing in different contexts, have to come together. By working together, they can represent a cohesive set of data or state that may be created by the sequence of instructions. In this section, we’ll see how to realize those two goals at the same time.

Let’s design a DSL to schedule meetings. A user of our DSL will be able to schedule them using a lightweight syntax like this one:

 schedule meeting {
  assign name ​"Meeting to discuss why meetings aren't effective"
  starts at 14..30
  ends at 15..30
  on date 15 March 2020
  participants include ​"Sara"​ and ​"Jake"​ and ​"Mani"
 }

Examining the syntax, we can see that schedule can be a singleton with meeting() as a function that takes a lambda as its parameter. But what about assign, starts, and so on? One approach may be to define each one of them as singletons. However, that approach has some problems. In addition to executing the functions like name, at, and include, we also need to gather the data needed to define a meeting. Arbitrary singletons aren’t great to carry a single representation of data; we need to look further.

An instance of a Meeting class would serve us well to hold the details of a meeting. Let’s see how we can maintain multiple contexts and at the same time maintain a single state of the data or details about a meeting.

The easiest way to design the first line in the DSL snippet, schedule meeting {, is using a schedule singleton with a function meeting. This function can create a Meeting object to serve as a context for the execution of the lambda expression passed to the meeting() function. Let’s write the schedule singleton first:

 object​ schedule {
 infix​ ​fun​ ​meeting​(block: Meeting.() -> Unit) = print(Meeting().apply(block))
 }

The meeting() function in the schedule singleton has been marked infix. The parameter declaration of the function shows that the lambda provided as parameter will be executed in the context of a Meeting instance. The function creates an instance of a yet-to-be-written Meeting class, invokes the given lambda in the context of the Meeting instance, and prints the details present in the instance.

The Meeting class will hold the details about the meeting. For that, let’s define the class with some predefined properties:

 class​ Meeting {
 var​ meetingName = ​""
 var​ startTime = IntRange.EMPTY
 var​ endTime = IntRange.EMPTY
 var​ scheduledOn = ​""
 var​ attending = ​""
 }

The class holds some properties to store the name of the meeting, the starting and the ending time, the date the meeting will be scheduled, and details of who will attend.

When the lambda passed to the meeting() function is executed, from within the lambda, we need to run instructions like assign name, starts at, and so on. Let’s take one line at a time and see how each one of them can be designed.

To set up a meeting, we need to assign a name for the meeting. That’s done, as an example, by this line:

 assign name ​"Meeting to discuss why meetings aren't effective"

We may be tempted to design assign as a singleton with name as a function in it. However, the name of the meeting should be stored in the Meeting instance. How in the world can we then connect the singleton assign to the instance of Meeting that is passed in as a receiver to the lambda? That’s not easy, if not impossible. Instead, we can take a simpler approach.

Take this line:

assign name sometext

We can look at it like this:

this.assign name sometext

That is, we can look at assign as a property on the implicit receiver object of the lambda. In fact, we can make that property assign refer back to the instance of Meeting and make name a function of Meeting. By doing so, we can easily store the name of the meeting into the Meeting instance, like so:

 //within the Meeting class
 val​ assign = ​this
 
 infix​ ​fun​ ​name​(name: String) {
  meetingName = name
 }

The name function takes a name for the meeting and assigns that to the meetingName property of Meeting. That takes care of assigning a name for the meeting. Let’s now focus on the next line, to define the start time for a meeting:

 starts at 14..30

At first thought, we could make starts refer to this, much like how assign merely refers to this. Then at() can be a function of Meeting. The downside to that approach is that a user may inadvertently write:

assign at sometext

And that will be an undesirable but correct syntax. As we’ve seen before, we can prevent such unintentional syntax by using different context objects.

Let’s create a starts property within Meeting but assign it to an instance of Starts, which is an inner class within Meeting. The benefit of an inner class is that its instances can provide a new context, and yet those instances can access the state in the outer instance.

 //within the Meeting class
 val​ starts = Starts()
 
 inner​ ​class​ Starts {
 infix​ ​fun​ ​at​(time: IntRange) {
  startTime = time
  }
 }

The Starts inner class’s at() function, marked as infix, takes the given time and assigns it to the startTime that resides in the Meeting instance.

The next line in the DSL snippet deals with the ending time of the meeting:

 ends at 15..30

This line can be designed in the same way as the previous line in the DSL:

 val​ ends = Ends()
 
 inner​ ​class​ Ends {
 infix​ ​fun​ ​at​(time: IntRange) {
  endTime = time
  }
 }

That takes us to the next line:

 on date 15 March 2020

This line looks fluent, but from the design and implementation point of view, it can seem overly complicated. Sometimes, to provide a small amount of fluency and ease to our users, we may have to walk a few extra miles (or kilometers, for those using evolved units of measure) when designing DSLs. In general, it’s well worth it. We put in the effort once, but our users reap the benefits every single time they use it, and silently praise our efforts. It’s a great way to score some thank you points.

We can look at this line as a series of function calls:

on.date(15).March(2020)

That tells us we need a context class On for the instance on with a date() function that takes a day of the month as its parameter. It can then return an instance, such as a DateCreator, which then has functions like March, April, and so on, that take the year as a parameter. That wasn’t that bad, except for one small detail—we need a way for the date to be set in the instance of Meeting. Again, we can leverage Kotlin’s capabilities for that. Let’s design that part now:

 val​ on = On()
 
 inner​ ​class​ On {
 infix​ ​fun​ ​date​(day: Int) = DateCreator(day, ​this​@Meeting)
 }
 
 class​ DateCreator(​val​ day: Int, ​val​ meeting: Meeting) {
 private​ ​fun​ ​setScheduledOn​(month: Int, year: Int) {
  meeting.scheduledOn =
  java.time.LocalDate.of(year, month, day).toString()
  }
 
 infix​ ​fun​ ​January​(year: Int) = setScheduledOn(1, year)
 infix​ ​fun​ ​February​(year: Int) = setScheduledOn(2, year)
 infix​ ​fun​ ​March​(year: Int) = setScheduledOn(3, year)
 //... April, May,... December
 }

The on variable refers to an instance of the inner class On. The date() function of On creates an instance of DateCreator, passes in to the constructor the given day and the reference to the instance of the outer class, using the syntax this@Meeting. From within an instance of an inner class, this refers to the instance of the inner, but this@OuterClassName refers to the instance of the outer class, where OuterClassName should be replaced with the actual name of the outer class.

The implementation of the DateCreator class is straightforward. The functions like March, one per month, set the value of the scheduledOn property on the Meeting instance appropriately.

Let’s now take a look at the last line within the lambda of the DSL example:

 participants include ​"Sara"​ and ​"Jake"​ and ​"Mani"

Implementing this, in comparison to what we’ve done so far, is relatively easy. We can set participants as a property in Meeting and refer to an instance of an inner class, Participants. That class can have a function named include() and a function named and(), like so:

 val​ participants = Participants()
 
 inner​ ​class​ Participants {
 infix​ ​fun​ ​include​(name: String): Participants = apply {
  attending = name
  }
 
 infix​ ​fun​ ​and​(name: String): Participants = apply {
  attending = ​"$attending, $name"
  }
 }

The include() and the and() functions append the given names to the attending property in the Meeting instance.

As a last step in the design, we need the toString() function to print the details of the meeting that are gathered in the instance created within the schedule’s meeting() function. Let’s implement the toString() function and, while we’re at it, also take a look at the entire code to process the DSL:

 object​ schedule {
 infix​ ​fun​ ​meeting​(block: Meeting.() -> Unit) = print(Meeting().apply(block))
 }
 
 class​ Meeting {
 var​ meetingName = ​""
 var​ startTime = IntRange.EMPTY
 var​ endTime = IntRange.EMPTY
 var​ scheduledOn = ​""
 var​ attending = ​""
 val​ assign = ​this
 val​ starts = Starts()
 val​ ends = Ends()
 val​ on = On()
 val​ participants = Participants()
 
 infix​ ​fun​ ​name​(name: String) {
  meetingName = name
  }
 
 inner​ ​class​ Starts {
 infix​ ​fun​ ​at​(time: IntRange) {
  startTime = time
  }
  }
 
 inner​ ​class​ Ends {
 infix​ ​fun​ ​at​(time: IntRange) {
  endTime = time
  }
  }
 
 inner​ ​class​ On {
 infix​ ​fun​ ​date​(day: Int) = DateCreator(day, ​this​@Meeting)
  }
 
 class​ DateCreator(​val​ day: Int, ​val​ meeting: Meeting) {
 private​ ​fun​ ​setScheduledOn​(month: Int, year: Int) {
  meeting.scheduledOn =
  java.time.LocalDate.of(year, month, day).toString()
  }
 
 infix​ ​fun​ ​January​(year: Int) = setScheduledOn(1, year)
 infix​ ​fun​ ​February​(year: Int) = setScheduledOn(2, year)
 infix​ ​fun​ ​March​(year: Int) = setScheduledOn(3, year)
 //... April, May,... December
  }
 
 inner​ ​class​ Participants {
 infix​ ​fun​ ​include​(name: String): Participants = apply {
  attending = name
  }
 
 infix​ ​fun​ ​and​(name: String): Participants = apply {
  attending = ​"$attending, $name"
  }
  }
 
 override​ ​fun​ ​toString​() = ​"""Meeting: $meetingName
  |Starts at ${startTime.start}:${startTime.endInclusive}
  |Ends at ${endTime.start}:${endTime.endInclusive}
  |On $scheduledOn
  |Participants: ${attending}
  |
  """​.trimMargin()
 }

We can compile the Meeting.kt file and use the generated JAR file in the classpath to execute the DSL snippet in schedule.kts, like so:

 kotlinc-jvm -d meeting.jar meeting.kt
 kotlinc-jvm -classpath meeting.jar -script schedule.kts

The output of the execution is shown here:

 Meeting: Meeting to discuss why meetings aren't effective
 Starts at 14:30
 Ends at 15:30
 On 2020-03-15
 Participants: Sara, Jake, Mani

This example showed us how multiple inner classes came together and used the instance of the outer class to share the state. Furthermore, the inner classes provided separate contexts for the functions and so removed the possibility of the user making ambiguous calls.

In this chapter, we looked at the benefits of creating context objects. You also saw how to create multiple context objects and how to leverage Kotlin capabilities like receivers for lambda, the it variable name, and inner classes. In the next chapter, we’ll take a look at controlling the scope of access and better error checking.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.139.81.58