Sealed Traits and Algebraic Datatypes
Let's deal with the second issue—let's encapsulate the logic of chatbot modes into the trait, which will only deal with logic and nothing else. Look at the following definition:
trait ChatbotMode { def process(message: String, effects: EffectsProvider): LineStepResult def or(other: ChatbotMode): ChatbotMode = Or(this,other) def otherwise(other: ChatbotMode): ChatbotMode = Otherwise(this,other) }
For now, let's ignore or
and otherwise
combinators and look at the process
method. It accepts input messages and effects and returns the processing result, which can be a failure or message sent to a user with the next state of the mode:
sealed trait LineStepResultcase class Processed( answer:String, nextMode: ChatbotMode, endOfDialog:Boolean) extends LineStepResult case object Failed extends LineStepResult
Here, we can see a new modifier: sealed
.
When a trait (or class) is sealed, it can only be extended in the same file, where it is defined. Due to this, you can be sure that, in your family of classes, nobody will be able to add a new class to somewhere in your project. If you do use case analysis with the help of the match/case expression, a compiler can do exhaustive checking: all of the variants are present.
Constructions from a family of case classes/objects, extended from a sealed
trait, is often named an Algebraic Data Type (ADT).
This term comes to us from the HOPE language (1972, Edinburg University), where all types can be created from an initial set of types with the help of algebraic operations: among them was a named product
(which looks like a case class in Scala) and distinct union
(modeled by the sealed trait with subtyping).
Using ADT in domain modeling is rewarding because we can do evident case analysis for the domain model and have no weak abstraction; we can implement various designs which can be added to our model in the future.
Returning to our ChatbotMode
.
On bye
, we must exit the program.
This is easy—just define the appropriative object:
object Bye extends ChatbotMode { override def process(message: String, effects: EffectsProvider): LineStepResult = if (message=="bye") { Processed("bye",this,true) } else Failed }
Now, we'll look at creating the same modes for the CurrentTime
query.
Note
The code for this exercise can be found in Lesson 2/3-project
.
The next step is to make a bigger mode from a few simpler modes. Let's look at the mode, which extends two modes and can select a mode which is able to process incoming messages:
case class Or(frs: ChatbotMode, snd: ChatbotMode) extends ChatbotMode { override def process(message: String, effects: EffectsProvider): LineStepResult ={ frs.process(message, effects) match { case Processed(answer,nextMode,endOfDialog) => Processed(answer, Or(nextMode,snd),endOfDialog) case Failed => snd.process(message,effects) match { case Processed(answer,nextMode,endOfDialog) => Processed(answer, Or(nextMode,frs),endOfDialog) case Failed => Failed}} }}
Here, if frs
can process a message, then the result of processing this is returned. It will contain an answer. NextMode
(which will accept the next sequence) is the same or
with nextMode
from frs,
processing the result and snd
.
If frs
can't answer this, then we try snd
. If snd's
processing is successful, then, in the next dialog step, the first message processor will be a nextStep
, received from snd
. This allows modes to form their own context of the dialog, like a person who understands your language. This will be the first thing you will ask next time.
We can chain simple modes into complex ones with the help of such combinators. Scala allows us to use fancy syntax for chains: any method with one parameter can be used as a binary operator. So, if we define the or
method in ChatbotMode
, we will be able to combine our modes:
def or(other: ChatbotMode): ChatbotMode = Or(this,other)
And later in main
, we can write this:
def createInitMode() = (Bye or CurrentDate or CurrentTime) otherwise InterestingIgnore
Otherwise
looks very similar, with one difference: the second mode must always be second.
When we write one, it looks like this.
def main(args: Array[String]): Unit = { val name = StdIn.readLine("Hi! What is your name? ") println(s" $name, tell me something interesting, say 'bye' to end the talk") var mode = createInitMode() var c = Processed("",mode,false) while(!c.endOfDialog){ c = c.nextMode.process(effects.input.read(),effects) match { case next@Processed(_,_,_) => next case Failed => // impossible, but let be here as extra control. Processed("Sorry, can't understand you",c.nextMode,false)} effects.output.writeln(c.answer)}}
We can make this a little better: let's move first the interaction (where the program asks the user for their name) to mode.
Now, we'll move the frst interaction to mode.
Here, we will make mode
, which remembers your name and can make one for you.
- Define a new object, which implements the
chatbot
trait and when running the first words,my name is
, accepts a name and answershi
, and then tells you your name:case class Name(name:String) extends ChatbotMode { override def process(message: String, effects: EffectsProvider): LineStepResult = { message match { case "my name" => if (name.isEmpty) { effects.output.write("What is your name?") val name = effects.input.read() Processed("hi, $name", Name(name), false) } else { Processed(s"your name is $name",this,false)}case _ => Failed } } } }
- Add this object to the sequence of nodes in
main:
def createInitMode() = (Bye or CurrentDate or CurrentTime or Name("")) otherwise InterestingIgnore
- Add a test with this functionality to testcase. Notice the usage of custom effects:
Note
For full code, refer to
Code Snippets/Lesson 2.scala
file.test("step of my-name") { val mode = Chatbot3.createInitMode() val effects = new EffectsProvider { override val output: UserOutput = (message: String) => {} override def input: UserInput = () => "Joe" override def currentDate(): LocalDate = Chatbot3.effects.currentDate() override def currentTime(): LocalTime = Chatbot3.effects.currentTime() } val result1 = mode.process("my name",effects) assert(result1.isInstanceOf[Processed]) val r1 = result1.asInstanceOf[Processed] assert(r1.answer == "Hi, Joe") val result2 = r1.nextMode.process("my name",effects) assert(result2.isInstanceOf[Processed]) val r2 = result2.asInstanceOf[Processed] assert(r2.answer == "Your name is Joe") }