Exit e-book
Show all chapters
14
Creando el modelo de dominio, al estilo funcional
14. 
Creando el modelo de dominio, al estilo funcional

Sign up to our Newsletter

Signing up to our newsletter allows you to read all our ebooks.

    Introducción a la Programación con Efectos Funcionales usando ZIO
    14

    Creando el modelo de dominio, al estilo funcional

    Primeramente, debemos definir nuestro modelo de dominio. Para ello definiremos algunas clases dentro del archivo com/example/package.scala.

    Como primer paso, podemos definir una clase Name que represente el nombre del jugador:

    
    final case class Name(name: String)
    
    

    Como podemos ver, definimos Name como una clase case en vez de definirla simplemente como una clase normal. Usar clases case nos da muchos beneficios, como por ejemplo:

    • Inmutabilidad por defecto.
    • Se genera un método apply de forma automática, lo cual nos permite construir objetos sin necesidad de usar la palabra reservada new.
    • Se genera un método unapply de forma automática, el cual puede ser usado en expresiones de reconocimiento de patrones (pattern matching en inglés).
    • Se genera un método copy de forma automática, el cual nos permite realizar copias de objetos y actualizar ciertos campos al mismo tiempo.

    Además, definimos la clase como final para que no pueda ser extendida por otras clases. Por otro lado, vemos que la clase Name simplemente encapsula un String que representa el nombre del usuario. Podríamos dejar este modelo así como está, sin embargo hay un problema. Nos permite crear objetos Name con un String vacío, lo cual es algo indeseado. Entonces, para impedir esto, podemos aplicar una técnica de diseño funcional llamada constructor inteligente (smart constructor en inglés), que simplemente consiste en definir un método en el objeto acompañante de la clase Name que nos permita hacer las validaciones respectivas antes de crear una instancia. Entonces, tendríamos algo así:

    
    final case class Name(name: String)
    object Name {
      def make(name: String): Option[Name] =
        if (!name.isEmpty) Some(Name(name)) else None
    }
    
    

    Como podemos ver, el método make corresponde a nuestro constructor inteligente, y éste verifica si el String recibido es vacío o no. Si no es vacío, devuelve un nuevo Name, pero encapsulado dentro de un Some, y si es vacío retorna un None. Lo importante a resaltar aquí es que el método make es una función pura, porque es total, determinística, la salida sólo depende del String de entrada y no produce efectos colaterales.

    Ahora bien, hay algunas cosas que podemos mejorar en nuestra implementación. Por ejemplo, aún es posible llamar directamente al constructor de Name con Strings vacíos. Para evitar esto, podemos convertirlo en privado:

    
    final case class private Name(name: String)
    object Name {
      def make(name: String): Option[Name] =
        if (!name.isEmpty) Some(Name(name)) else None
    }
    
    

    De esta forma, la única forma de construir objetos Name es llamando al método Name.make, al menos eso es lo que se espera. Sin embargo, aún podemos construir objetos Name con  Strings vacíos usando el método Name.apply. Por otro lado, hay también otro problema que debemos resolver, el cual es que gracias al método copy que es automáticamente generado para las clases case, después de haber creado un objeto Name válido usando Name.make, podríamos luego generar un objeto inválido generando una copia con un String vacío, y nada nos impediría hacer eso. Para evitar esto, podemos definir la clase como sealed abstract en vez de final, lo cual significa que no se generará los métodos apply y copy:

    sealed abstract case class Name private (name: String)
    object Name {
      def make(name: String): Option[Name] =
        if (!name.isEmpty) Some(new Name(name) {}) else None
    }
    
    
    

    De esta forma, estamos completamente seguros que cualquier objeto Name en nuestra aplicación siempre será válido.

    Similarmente, podemos definir una clase case llamada Guess, que represente cualquier letra adivinada por el jugador:

    sealed abstract case class Guess private (char: Char)
    object Guess {
      def make(str: String): Option[Guess] =
        Some(str.toList).collect {
          case c :: Nil if c.isLetter => new Guess(c.toLower) {}
        }}
    
    
    

    Como podemos ver, hemos usado la misma técnica de definir un constructor inteligente, el cual recibe un String introducido por el jugador, y verifica si solamente consiste de una letra. Si ése no es el caso (por ejemplo cuando el String introducido por el jugador consiste de varios caracteres o cuando introduce un número o un símbolo) se retorna None.

    Luego, podemos definir otra clase case llamada Word, que represente la palabra a ser adivinada por el jugador:

     

    sealed abstract case class Word private (word: String) {
      def contains(char: Char) = word.contains(char)
      val length: Int          = word.length
      def toList: List[Char]   = word.toList
      def toSet: Set[Char]     = word.toSet
    }
    object Word {
      def make(word: String): Option[Word] =
        if (!word.isEmpty && word.forall(_.isLetter)) Some(new Word(word.toLowerCase) {})
        else None
    }
    
    
    

    Otra vez tenemos un constructor inteligente que verifica que una palabra no sea vacía y que contenga solamente letras, no números ni símbolos. Adicionalmente esta clase contiene algunos métodos útiles (todos son funciones puras, por cierto):

    • Saber si una palabra contiene un caracter determinado (contains).
    • Obtener la longitud de una palabra (length).
    • Obtener una lista con los caracteres de la palabra (toList).
    • Obtener un Set con los caracteres de la palabra (toSet).

    A continuación definimos otra clase case llamada State, la cual representa el estado interno de la aplicación, que incluye:

    • El nombre del jugador (name).
    • Las letras adivinadas hasta el momento (guesses).
    • La palabra a adivinar (word)
    sealed abstract case class State private (name: Name, guesses: Set[Guess], word: Word) {
      def failuresCount: Int            = (guesses.map(_.char) -- word.toSet).size
      def playerLost: Boolean           = failuresCount > 5
      def playerWon: Boolean            = (word.toSet -- guesses.map(_.char)).isEmpty
      def addGuess(guess: Guess): State = new State(name, guesses + guess, word) {}
    }
    object State {
      def initial(name: Name, word: Word): State = new State(name, Set.empty, word) {}
    }
    

    Similarmente, tenemos un constructor inteligente State.initial, que permite instanciar el estado inicial del juego con el nombre del jugador y la palabra que tiene que adivinar, obviamente con un Set vacío de letras adivinadas. Por otro lado, State incluye algunos métodos útiles:

    • Obtener la cantidad de fallas del jugador (failuresCount)
    • Saber si el jugador perdió (playerLost)
    • Saber si el jugador ganó (playerWon)
    • Añadir una letra al Set de letras adivinadas (addGuess)

    Finalmente, definimos un modelo GuessResult que representa el resultado obtenido por el jugador después de adivinar una letra. Este resultado puede ser uno de los siguientes casos:

    • El jugador ganó, porque adivinó la última letra que faltaba.
    • El jugador perdió, porque usó el último intento que le quedaba.
    • El jugador adivinó correctamente una letra, aunque todavía le faltan letras para ganar.
    • El jugador adivinó incorrectamente una letra, aunque todavía le quedan más intentos.
    • El jugador repitió una letra que ya había adivinado anteriormente, por lo tanto el estado del juego no cambia.

    Esto lo podemos representar con una enumeración, de la siguiente forma:

    sealed trait GuessResult
    object GuessResult {
      case object Won       extends GuessResult
      case object Lost      extends GuessResult
      case object Correct   extends GuessResult
      case object Incorrect extends GuessResult
      case object Unchanged extends GuessResult
    }
    

    En este caso una enumeración tiene sentido pues GuessResult sólo puede tener un valor posible de entre los mencionados en la lista de opciones. Algunos detalles importantes aquí:

    • Definimos una enumeración como un sealed trait.
    • La palabra sealed es importante, pues eso asegura que todas las clases que pueden extender GuessResult están en el mismo archivo (package.scala) y que ninguna otra clase fuera de este archivo podrá extender GuessResult.
    • La palabra sealed es importante además porque de esta forma el compilador nos puede ayudar cuando usamos reconocimiento de patrones (pattern matching en inglés) en instancias de GuessResult, advirtiéndonos si no hemos considerado todas las opciones posibles.
    • Los valores posibles de GuessResult están definidos dentro de su objeto acompañante.
    PREVIOUS
    Chapter
    13
    NEXT
    Chapter
    15