El poder de Option: Más allá del pattern matching

Una de las primeras cosas a las que le tomamos gusto cuando aprende Scala, sobre todo si venimos de Java, es al pattern matching, aunque una de las cosas que desconciertan un poco es la manera en que funcionan los mapas.

En Java, si tenemos un java.util.Map simplemente le pedimos el valor para una llave, y nos devuelve el valor, o null si no lo tiene. O si el mapa acepta nulos, entonces puede devolvernos null si es que tiene null guardado bajo la llave que indicamos. ¿Cómo podemos diferenciar entre el caso en que el mapa no tiene la llave, o si tiene almacenado null bajo esa llave? Podemos verificar usando containsKey.

Entonces, tenemos los siguientes casos:

if (map.get("X") != null) {
  /* obtuvimos un valor */
} else if (map.containsKey("X")) {
  /* Tenemos null almacenado bajo la llave
} else {
  /* No existe esa llave en el mapa */

}

¿De verdad nos gusta hacer esto en Java? En la práctica, casi no tenemos mapas donde guardemos null, incluso algunos lo consideran una mala práctica. Pero puede darse el caso... pero normalmente tenemos solamente algo como esto:

X x = mapa.get("X");
if (x ==null) {
  x = new X();
  mapa.put("X", x);
}

Y a partir de ahí ya estamos seguros que tenemos un X. Pero bueno, los que llevamos tiempo en Java ya estamos acostumbrados a lidiar así con los mapas. Y luego llegamos a Scala y resulta que es algo completamente distinto:

var x = mapa("X")

Me devuelve el valor almacenado bajo la llave "X", o arroja una excepción si el mapa no tiene esa llave. Qué molesto tener que poner cada acceso a un mapa en un try-catch! Pero hay otra manera:

var x = mapa.get("X")

Pero resulta que el método get del mapa en Scala no devuelve el valor directamente, sino una cosa llamada Option.

Option básico

Esta clase abstracta Option puede tener dos encarnaciones en la práctica: Some y None. None pues significa que no hay nada, y Some es un contenedor de un objeto (que puede ser null, por cierto). De modo que con esa última línea de código, x puede ser None o puede ser Some(algo), donde algo es el objeto almacenado en el mapa, el que realmente nos interesa.

Entonces, leyendo un poco nos encontramos con que podemos hacer algo así, si queremos obtener el mismo comportamiento del último ejemplo que puse en Java, donde obtenemos el valor y si no está lo creamos y almacenamos:

var x = mapa.get("X") match {
  case Some(algo) => algo //devolvemos el objeto interno
  case None =>
    mapa("X")=new X() //creamos uno nuevo y lo almacenamos
    mapa("X") //devolvemos directamente el valor, sabemos que no arrojará excepción porque acabamos de almacenarlo
}

Si el mapa tiene el valor, entraremos al caso de Some(x) donde simplemente devolvemos el valor contenido en el Some. Si no tiene el valor, entramos al caso None, en donde creamos el objeto que necesitamos, lo ponemos en el mapa y lo devolvemos, usando el método apply, el cual no arrojará excepción en este caso porque acabamos de poner el valor en el mapa.

Más allá del pattern matching

Pero Option no nada más es un simple contenedor de valores. Tiene varios métodos muy útiles, que nos permiten encadenar la ejecución de distintas funciones, cada una operando sobre el resultado de la anterior. Un ejemplo es el método map. Supongamos que tenemos un mapa cuyos valores son cadenas y queremos obtener una de esas cadenas, pero en mayúscula y alrevés. Al final haremos pattern matching pero sólo sobre el resultado final, no tenemos que estar manejando condiciones intermedias:

mapa.get("llave").map(_.toUpperCase).map(_.reverse) match {
  case Some(s) => println(s) //Aqui la cadena ya viene transformada
  case None => println("no hay cadena")
}

Si el mapa tiene una cadena bajo la llave indicada, obtendremos un Option, sobre el cual invocamos map con la función anónima _.toUpperCase, la cual invocará sobre la cadena contenida en el Some; el resultado de eso será otro Some pero que ahora contiene la cadena en mayúsculas; a ese le invocaremos map con la función anónima _.reverse y el resultado será otro Some que contiene la cadena alrevés. De este modo, si la cadena original era "hola", al final tenemos "ALOH" y sobre eso se hace el match.

Si el mapa no tuviera una cadena bajo la llave indicada, entonces devuelve None. Cuando se invoca map sobre None, no se ejecuta la función que recibe como parámetro, simplemente se devuelve None.

Las funciones que se pasan al método map pueden incluso devolver un objeto de una clase distinta. Por ejemplo si hacemos algo así:

mapa.get("llave").map(_.length)

Obtendremos al final un Option con la longitud de la cadena (un Int).

Option tiene varios métodos que resultan muy útiles para operar directamente sobre los valores sin tener que estar comprobando si los tenemos o no, lo cual nos ahorra el insertar varios if's. Algunos ejemplos son filter, orElse, getOrElse, orNull:

val opt=mapa.get("llave")

//filter devuelve la opción si su valor cumple la condición, o None
//orElse devuelve la opción si es un Some o el resultado de la función, que debe ser un Option
opt filter(_.startsWith("h")) orElse(Some("no empieza con 'h'"))

//Esto nos da el mismo comportamiento que un Map en Java
val cadena=mapa.get("llave").orNull

//Esto nos da el valor de la opción, o el resultado de la evaluar la función alterna si la opción es None
val c2=opt getOrElse("Otra Cosa")

Este tipo de cosas es lo que nos permite usar un estilo de programación más declarativo, en vez de imperativo. Regresando al ejemplo de obtener un objeto de un mapa y agregarlo en caso de no estar, resulta que los mapas en Scala son animales bastante más sofisticados que los mapas de Java. En Scala, un mapa también tiene varios métodos muy útiles como collect, exists, filter, find, getOrElse y getOrElseUpdate, entre otros. Este último nos sirve precisamente para realizar esa operación que ya vimos en Java y también en Scala con pattern matching, pero en un solo paso:

val x = mapa.getOrElseUpdate("X", { new X() })

Si el mapa contiene la llave "X", devolverá el valor de la misma, ya no como un Option sino directamente el valor; pero si no tiene un valor bajo esa llave, entonces ejecuta la función que se le pasa como segundo parámetro, almacena el resultado de la misma bajo la llave solicitada, y lo devuelve. Esta última versión no sólo es más breve que la de 4 líneas en Java y sobre todo que la de 5 líneas en Scala, sino que por el nombre del método queda más claro lo que está haciendo.

En fin, este tipo de cosas son las que he ido descubriendo en mis exploraciones de Scala, espero les sean de utilidad.

Comentarios

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.
Imagen de bferro

Los ejemplos de Option de@ezamudio me dan pie para:

Muy buen post sobre Option, Some y None. Y muy adecuados para introducir "CallBy Name" en Scala.

Scala nos ofrece dos alternativas para evaluar los argumentos que se le pasan a una función: Call by Value y Call by Name.

Típicamente los parámetros de las funciones son parámetros "by value". Esto significa que la expresión que es pasada como argumento a una función se determina o evalúa antes que comience a ejecutarse la función.

En ocasiones necesitamos escribir funciones que aceptan una expresión como parámetro que no queremos que se evalúe hasta que ella sea llamada dentro del cuerpo de la función. Scala ofrece esa alternativa definiendo que los parámetros sean "by name" y para eso ofrece una sintaxis especial.

Un parámetro "by name" se especifica omitiendo los paréntesis que normalmente acompañan un parámetro de una función:

def funcionCallByName(parametroCallByName: =>Tipo de Retorno).

Ese mecanismo es el que utiliza por ejemplo el método getOrElse de la clase Option para hacer lo que hace. El código de ese método es el siguiente:

@inline final def getOrElse[B:>A](default: =>B): B = if (isEmpty) default else this.get

La expresión default no es evaluada hasta tanto no sea usada dentro del cuerpo de la función.

A continuación un ejemplo bonito tomado de "Scala in Depth".

Seguramente hemos tenido necesidad en Java de crear directorios temporales para diferentes programas que escribimos. Se puede dar el caso que especificamos ese directorio temporal o que nos satisface usar el directorio temporal que está especificado en la propiedad correspondiente de la máquina virtual.
Podemos entonces escribir una función que acepte como argumento un Option, cuyo valor puede ser None y en ese caso NO estamos especificando el directorio , o cuyo valor sea un contenedor Some que especifique el directorio.
La siguiente función resuelve ese problema:

def getTemporaryDirectory(tmpArg: Option[String]):java.io.File = {
  tempArg.map(name => new java.io.File(name)).filter(_.isDirectory).
  getOrElse(new java.io.File(System.getProperty("java.io.tempdir")))
}

La expresión que recibe como argumento la función getOrElse será evaluada dentro del cuerpo de la función solo en el caso que tmpArg sea None.

Evaluamos esa función en el REPL. Tengo en mi máquina un directorio "/borrame" pero NO tengo un directorio "/eraseme".

scala>getTemporaryDirectory(Option("/borrame")
res0: java.io.File = \borrame
scala>getTemporaryDirectory(Option("/eraseme")
res1: java.io.File = C:Users\ADMINI~1\AppData\Local\Temp

Muy mona esa mónada.

Entendiendo como funciona Option se comienzan a abrir más y más caminos hacia la programación funcional y nos empezamos a alejar de la programación Orientada a Objetos / procedural.

Lo bueno de Scala es que permite hacer este cambio gradual y no te pone en una posición de todo o nada. Es por eso que me parece que la parte OO de Scala es solo un gancho que permite atraer a los programadores y es claro que fue una buena decision.

La clase Option es una mónada ( ja ja casi suena antinatural decir eso :P ) que permite el estilo de programación funcional como lo muestra el post, sin tener que andar haciendo validaciones como revisar si el valor es null y eso. Lo que me parece algo raro pero voy entendiendo cada vez más, es que este tipo de construcciones se pusieron en las bibliotecas base y no tanto en el lenguaje mismo, me pregunto si la razón fue para acelerar la implementación del lenguaje?

Les dejo un ink en StackOverflow sobre el uso de Option en esta pregunta: Why Option[T]?

Para entender más sobre las mónadas les recomiendo este artículo que de todos los que he encontrado me ha parecido el más fácil de entender.

Monads are not metaphors

+1 por el artículo y esperamos más para seguir aprendiendo Scala y programación funcional con cosas como currying, tipos algebraicos, tipos de alto nivel ( higher kinds ), polimorfismo paramétrico y todas esas cosas que si bien hay mucha literatura en internet, a veces parecen más intimidantes de lo que en realidad son y resultan más interesantes cuando alguien escribe un ejemplo como en tus posts.

Imagen de bferro

Sigo aprovechando la discusión de Option

Aprovecho este post para abundar un poco más sobre algunas "cosas" interesantes del lenguaje Scala.Espero continuar con la parte 3 de Scala en algún momento. El tiempo libre no abunda.

Las clases (objetos)  Option, Some y None son relativamente sencillas y podemos entender su código con relativa facilidad. Se aprende a programar, programando y leyendo programas, por lo que conviene aquí copiar el código fuente de estas clases y revisarlo, para ver algunos conceptos importantes.

Option es una clase abstracta sellada que viene acompañada con su objeto "acompañante" (companion object). Ambas cosas se incluyen en el archivo Option.scala en la distribución del código fuente de Scala. En ese archivo también se incluye las definiciones de Some y None .

A continuación ese código (conviene leerlo con paciencia).

package scala

object Option {

  implicit def option2Iterable[A](x0:Option[A]) :Iterable[A]  = x0.toList

  def apply[A](x:A):Option[A] = if (x == null) None else Some(x)

  def empty[A]: Option[A] = None

}  

sealed abstract class Option[+A] extends Product with Serializable {
  self =>
 
  def isEmpty: Boolean

  def isDefined: Boolean = !isEmpty

  def get: A

  @inline final def getOrElse[B>:A](default :=>B): B =
    if (isEmpty) default else this.get

  @inline final def  orNull[A1 >: A](implicit ev:Null <:<  A1):A1 =
    this getOrElse null

  @inline final def map[B](f: A => B): Option[B] =
    if (isEmpty) None else (Some(f(this.get))

  @inline final def  flatMap[B](f: A=>Option[B]): Option[B] =
    if(isEmpty) None else f(this.get)

  @inline final def filter(p: A => Boolean): Option[A] =
    if( isEmpty || p(this.get) )) this  else None

  @inline final def filterNot(p: A => Boolean): Option[A] =
    if( isEmpty || !p(this.get) ) this  else None

  def withFilter(p: =>Boolean): WithFilter =new WithFilter(p)

  class WithFilter(p: =>Boolean) {
    def map[B](f: A => B):Option[B] = self filter p map f
    def flatMap[B](f: A => Option[B]):Option[B] = self filter p flatMap f
    def foreach[U]( f: A => U): Unit = self filter p foreach f
    def withFilter(q: A=>Boolean):WithFilter = new WithFilter(x =>p(x) && q(x) )
  }

  @inline final def exists(p: A=>Boolean): Boolean =
    !isEmpty && p(this.get)

  @inline final def foreach[U](f: A => U) {
    if (!isEmpty) f(this.get)

  def collect[B] (pf: PartialFunction[A, B]): Option[B] =
    if (!isEmpty && pf.definedAt(this.get) ) Some( pf(this.get) ) else None

  @inline final def orElse[B >: A](alternative: => Option[B]): Option[B] =
    if (isEmpty) alternative else this

  def iterator: Iterator[A] =
    if (isEmpty) collection.Iterator.empty else collection.Iterator.single(this.get)

  def toList:List[A] =
    if (isEmpty) List() else List(this.get)

  @inline final def toRight[X](left: =>X) =
    if (isEmpty) Left(left) else Right(this.get)

  @inline final def toLeft[X](right: =>X) =
    if (isEmpty) Right(right) else Left(this.get)

}

final case class Some[+A] (x:A) extends Option[A] {
  def isEmpty =false
  def get =x
}

case Object None extends Option[Nothing] {
  def isEmpty =true
  def get= throw new NotSuchElementException("None.get")
}

Toca ahora comentar ese código.

Se definen en ese archivo varias cosas:

  • El objeto Option singleton que acompaña a la clase Option.
  • La clase Option abstracta y sellada (sealed)
  • La clase anidada WithFilter que es usada por el método withFilter de la clase Option.
  • La clase Some final que hereda de la clase Option y define los métodos abstractos isEmpty y get en la clase Option.
  • El objeto None singleton que hereda de la clase Option y define los métodos abstractos isEmpty y get en la clase Option.

El método:

implicit def option2Iterable[A](x0:Option[A]) :Iterable[A]  = x0.toList

Es el método de conversión implícita para convertir un valor de tipo Option en un valor de tipo Iterable. Cuando el compilador encuentra un valor de tipo Option en el lugar que le corresponde a un valor de tipo Iterable, aplica de manera automática este método, para poder entonces usar toda la funcionalidad del trait y el objeto singleton Iterable, sobre un valor de tipo Option.
Iterable es un trait base para todas las colecciones de Scala que brindan un método  iterator.
El código de option2Iterable convierte el valor de tipo Option en una lista (que por supuesto es iterable).

El método:

def apply[A](x:A):Option[A] = if (x == null) None else Some(x)

Es el método para fabricar objetos de tipo Option. Es el método que se aplica cuando usamos la sintaxis dulce Option(valor). Sigue un ejemplo:

scala>val opcion1 = Option("javamexico")
opcion1: Option[java.lang.String] =Some(javamexico)

scala>val opcion2 =Option.apply("javamexico")
opcion2: Option[java.lang.String] =Some(javamexico)

scala> opcion1 == opcion2
res0: Boolean =true

scala>

El método:
def empty[A]: Option[A] = None

Es un método de fábrica utilitario que crea un valor de tipo None. Se incluye este método para lograr consistencia con la jerarquía de colecciones de Scala. Todas las colecciones en Scala tienen este método. Sigue un ejemplo:

scala>val none1 =Option empty
none1:Option[Nothing] =None

scala>

Aparece en el código anterior el tipo Nothing. Es un subtipo de todos los tipos en Scala, incluyendo los tipos valores que descienden de AnyVal y de los tipos referencias que descienden de AnyRef. No existen instancias del tipo Nothing.

La clase Option

La definición de esta clase:
sealed abstract class Option[+A] extends Product with Serializable

Varias cosas:

  • Option es una clase abstracta
  • Option es una clase parametrizada covariante. Sigue un ejemplo de covarianza:
    scala>val opcion: Option[Object] = Option("javamexico")
    opcion: Option[java.lang.Object] = Some("javamexico")

    scala>

  • La clase Option hereda del trait Product que es el trait base para todos los tipos productos,incluyendo los tipos Tuple1 a Tuple22. Sigue un ejemplo:
    scala> val opcion: Option[String] = Option("javamexico")
    opcion: Option[java.lang.String] = Some(javamexico)

    scala> opcion productArity
    res2: Int = 1

    scala>

  • Option es una clase sellada (sealed). Las clases selladas son importantes cuando se quiere asegurar que la jerarquía de clases case que derivan de una clase es fija y no puede ampliarse,de forma que nadie puede crear una nueva subclase case. Para esto, el lenguaje obliga a que todas las clases y objetos case se definan en el mismo archivo fuente de la clase sellada. Es el caso de la clase Option, de la clase case Some y del objeto case None. El compilador conoce todos los valores que pueden aparecer en una expresión match con un valor de tipo Option. En este caso esos valores pueden ser Some y None.

Los métodos de la clase Option
Varios de los métodos de la clase Option son fáciles de entender. Otros requieren discutir conceptos importantes de Scala. Teniendo en cuenta que este post ya se "alargó", prefiero entonces dejar la discusión de esos métodos complicados para el siguiente post y pasar entonces a comentar a Some y None.

Puede observarse del código, que los métodos  isEmpty y get en la clase Option son abstractos. Su definición la brinda la clase Some que hereda de Option y el objeto None que también hereda de Option.
En ambos casos el código es trivial y no es necesaria una explicación detallada.

Continúo en el siguiente post.

Imagen de bferro

Programación orientada a expresiones

Enrique (@ezamudio) escribe en el post que comienza este hilo:
Este tipo de cosas es lo que nos permite usar un estilo de programación más declarativo, en vez de imperativo.
El término que se usa para describir lo que Enrique menciona como programación declarativa es "programación orientada a expresiones" que es una característica de los lenguajes funcionales, donde lo que abunda son las expresiones que es algo que se evalúa y por tanto regresa un valor, y lo que escasea son las sentencias (statements) que es algo que se ejecuta y no regresa ningún valor.
Casi todos los bloques de control son expresiones en los lenguajes funcionales. Ya en algún momento discutimos sobre eso para las expresiones if en el lenguaje Scala y la No necesidad de disponer en Scala del operador ternario ?:que en Java y otros lenguajes es necesario para lograr lo que una expresión if logra.

Curiosamente, el estilo de programación imperativa ligado con expresiones en ocasiones provoca errores. En C por ejemplo, donde no existe un tipo boolean, la expresión de condición para un if puede ser cualquier expresión. Es común para los que empiezan con ese lenguaje escribir un operador de asignación en lugar de un operador == en la expresión que sirve como condición a un if. Algo así:

if(a=5) expresion else expresion2

La asignación en C es una expresión que devuelve un valor, en este caso el valor 5 por lo que la condición siempre será verdadera.

A pesar de eso, como expresa @ezamudio en su post la orientación a expresiones ofrece algunas ventajas que deben tenerse en cuenta a la hora de expresar la computación que se desea realizar y es el estilo de la programación funcional. Aprovecharla vale la pena.