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:
/* 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:
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:
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:
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
:
//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:
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.
- ezamudio's blog
- Inicie sesión o regístrese para enviar comentarios
Comentarios
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:
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: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:
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 quetmpArg
sea None.Evaluamos esa función en el REPL. Tengo en mi máquina un directorio "/borrame" pero NO tengo un directorio "/eraseme".
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.
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 archivoOption.scala
en la distribución del código fuente de Scala. En ese archivo también se incluye las definiciones deSome y None
.A continuación ese código (conviene leerlo con paciencia).
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:
Option
singleton que acompaña a la claseOption
.Option
abstracta y sellada (sealed)WithFilter
que es usada por el métodowithFilter
de la claseOption
.Some
final que hereda de la claseOption
y define los métodos abstractosisEmpty y get
en la claseOption
.None
singleton que hereda de la claseOption
y define los métodos abstractosisEmpty y get
en la claseOption
.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 tipoIterable
. Cuando el compilador encuentra un valor de tipoOption
en el lugar que le corresponde a un valor de tipoIterable
, aplica de manera automática este método, para poder entonces usar toda la funcionalidad del trait y el objeto singletonIterable
, sobre un valor de tipoOption
.Iterable
es un trait base para todas las colecciones de Scala que brindan un métodoiterator
.El código de
option2Iterable
convierte el valor de tipoOption
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 dulceOption(valor)
. Sigue un ejemplo: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: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 deAnyVal
y de los tipos referencias que descienden deAnyRef
. No existen instancias del tipoNothing
.La clase
Option
La definición de esta clase:
sealed abstract class Option[+A] extends Product with Serializable
Varias cosas:
opcion: Option[java.lang.Object] = Some("javamexico")
scala>
Product
que es el trait base para todos los tipos productos,incluyendo los tiposTuple1 a Tuple22
. Sigue un ejemplo:opcion: Option[java.lang.String] = Some(javamexico)
scala> opcion productArity
res2: Int = 1
scala>
Option
, de la clase caseSome
y del objeto caseNone
. El compilador conoce todos los valores que pueden aparecer en una expresión match con un valor de tipoOption
. En este caso esos valores pueden serSome
yNone
.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 claseOption
son abstractos. Su definición la brinda la claseSome
que hereda deOption
y el objetoNone
que también hereda deOption
.En ambos casos el código es trivial y no es necesaria una explicación detallada.
Continúo en el siguiente post.
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ónif
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í:
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.