Professional Scala
上QQ阅读APP看书,第一时间看更新

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.

  1. Create a new file in the CurrentTime modes package.
  2. Add one to the chain of modes in Main (for example, the Modify definition of createInitMode).
  3. Make sure that test, which checks the time functionality, is passed.

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.

  1. Define a new object, which implements the chatbot trait and when running the first words, my name is, accepts a name and answers hi, 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
    }
    }
    }
    }
  2. Add this object to the sequence of nodes in main:
    def createInitMode() = (Bye or CurrentDate or CurrentTime or Name("")) otherwise InterestingIgnore
  3. 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")
    
    }