In the post from two weeks ago, we solved the problem using Object-Oriented Programming:
we modeled the problem space using objects.
For an object to communicate with another one, a dispatch()
method was made available.
This is the 9th post in the Exercises in Programming Style focus series.Other posts include:
- Introducing Exercises in Programming Style
- Exercises in Programming Style, stacking things up
- Exercises in Programming Style, Kwisatz Haderach-style
- Exercises in Programming Style, recursion
- Exercises in Programming Style with higher-order functions
- Composing Exercises in Programming Style
- Exercises in Programming Style, back to Object-Oriented Programming
- Exercises in Programming Style: maps are objects too
- Exercises in Programming Style: Event-Driven Programming (this post)
- Exercises in Programming Style and the Event Bus
- Reflecting over Exercises in Programming Style
- Exercises in Aspect-Oriented Programming Style
- Exercises in Programming Style: FP & I/O
- Exercises in Relational Database Style
- Exercises in Programming Style: spreadsheets
- Exercises in Concurrent Programming Style
- Exercises in Programming Style: sharing data among threads
- Exercises in Programming Style with Hazelcast
- Exercises in MapReduce Style
- Conclusion of Exercises in Programming Style
Event-Driven Programming
Remember before the web was ubiquitous? Graphical user interfaces were already a thing. One great and widespread way to handle user interactions was - and still is - Event-Driven Programming: this has been popularized as the Observer design pattern.
The Observer is a viable alternative to the dispatch()
method.
The main difference is that by sending messages, there’s no return value.
Modeling the solution
The final class model looks like the following:
The model - as well as the next one - comes from the original Python solution. For Kotlin, I’ve just added the types.
The sequence diagram is:
The most important difference with the previous design is:
there’s no dispatch()
method anymore.
As a consequence, there’s no return value as well, but at the end of the diagram.
Note that the initial design had no return value at all! The reason for that is that it only printed the word frequencies. Hence, it was not possible to test easily. Because one of my requirements is to make sure the implementation is correct, I changed the design to actually return the map of word frequencies as a value.
Managing event handlers
An event handler is just a higher-order function, a function that can be passed around as any other type.
In the above design, classes register their methods as higher-order functions to other classes, e.g. in the init
block of WordFrequencyCounter
:
dataStorage.registerForWordEvents { incrementCount(it) }
wfApp.registerForEndEvents { getTop25() }
There are several issues to solve regarding event handlers:
- Ordering
-
Registering an event handler is easy: just provide a function with the right signature, and off you go. However, event handlers are triggered by events, and events might come in a different order than the one in which they need to be processed. Note that while this is not true with synchronous messaging - as it’s the case here - it’s still is harder to reason about than direct API calls.
To benefit from ordering anyway, one can store event handlers in different "buckets". Buckets can store handlers that don’t need to be ordered. At that point, one can invoke handlers from those buckets, starting with "Bucket 1", then "Bucket 2", etc. In the above design, there are 3 buckets: one for initialization handlers, one for work handlers, and the
- Storing
-
In the Python sample, storing handlers in different buckets is just to call them in the order they were meant to be. In Kotlin, one also needs to keep types into account:
Description Type Initialization event handlers consume something
(String) → Unit
Work handlers run by changing their internal state. Neither input nor output is necessary.
() → Unit
The single end handler needs to provide the word frequencies
() → Map<String, Int>
For the problem at hand, lambda types are compatible with each other.
In a real-world scenario, this probably wouldn’t be the case because there would be many handlers. Thus, the signature would need to be more generic, and casting necessary.
- Invoking
-
Just storing handlers are not the end: at some point, they need to be invoked. As I mentioned above, some return a value, while some need a parameter - or many. For example, reading the text sample requires the file name. Hence, the following code:
class DataStorage( wfApp: WordFrequencyFramework, private val stopWordsFilter: StopWordsFilter ) { init { wfApp.registerForLoadEvents { load(it) } } private fun load(filename: String) { data = read(filename) .flatMap { it.split("\\W|_".toRegex()) } .filter { it.isNotBlank() && it.length >= 2 } .map(String::toLowerCase) } // Abridged for readability }
Another initialization takes place in the StopWordsFilter
class.
It also loads a file - the stop words file - but it’s not parameterized by the file name.
However, because it needs to be stored in the same bucket, it needs to accept an ignored parameter of type String
:
class StopWordsFilter(wfApp: WordFrequencyFramework) {
init {
wfApp.registerForLoadEvents { load(it) }
}
private fun load(ignore: String) { (1)
stopWords = read("stop_words.txt")[0].split(",")
}
// Abridged for readability
}
1 | This is unfortunately required. At least, let’s name the parameter in a way it provides a hint. |
The main issue with Event-Driven Programming
Event-Driven Programming suffers from a big issue: complexity. While in the context of our simple exercise, it’s pretty manageable, it can quickly escalate when the number of classes grows.
Let’s have two classes, and picture them as nodes: there can be a single edge between them. With three nodes, the count is 3. Beyond that, it grows according to this table:
Number of nodes | Number of edges |
---|---|
2 |
1 |
3 |
3 |
4 |
6 |
5 |
10 |
6 |
15 |
… |
… |
n |
n * (n - 1) / 2 |
Visibly, reasoning about event handling quickly becomes impossible.
Conclusion
Event-Driven Programming is a great asset in some contexts e.g. GUI. In that context, the number of event-sending classes, of event-receiving classes and possible relationships between them is pretty limited. As soon as the later count grows, the Observer pattern becomes quite complex because each observer needs to reference each subject. In next week’s post, we will study a possible solution to that issue.