Scala: Clases "case" y pattern matching (1/2)
Las clases case (no encuentro un término adecuado en español) son uno de los mecanismos que Scala utiliza para hacer "pattern matching" sobre objetos, sin la necesidad de escribir el código de boilerplate que se requeriría si esta opción no existiera.
Pattern matching es una necesidad en la programación, y en el caso particular de la programación funcional es una técnica natural para resolver muchos problemas que hacen uso de datos recursivos con estructuras arbóreas.
Scala contribuye al pattern matching sobre objetos tratando de lograr cierta uniformidad de la sintaxis funcional y la sintaxis orientada a objetos.
Para describir las clases case y su utilización en el pattern matching necesitamos también hablar de otras cosas como las clases sealed (selladas) y del tipo Option y sus "derivados".
Utilizo aquí los ejemplos del texto de Odersky. Es un caso de estudio sencillo de comprender, y así no se pierde el tiempo. No veo necesario inventarme otros ejemplos.
Veamos primero las clases case
Supongamos que necesitamos escribir código para manipular expresiones aritméticas. Podemos requerir de eso si por ejemplo estamos diseñando un lenguaje específico de dominio (DSL). Queremos cumplir con el requisito mínimo de un DSL de ofrecer en el lenguaje una sintaxis y semántica similar a la sintaxis y semántica del lenguaje "natural" usado en ese dominio.
Nuestros datos de entrada en este caso de estudio son expresiones aritméticas y por tanto lo primero que hacemos es idear una solución para definir esas expresiones aritméticas.
Para propósitos del ejemplo restringimos el universo de las expresiones aritméticas a expresiones compuestas exclusivamente por variables, números y operaciones unarias y binarias. Una operación unaria está compuesta por un operador unario y un operando, mientras que una operación binaria tiene un operador binario y dos operandos.
Siempre es conveniente usar un caso de estudio de juguete cuando se quiere explicar otra cosa. Este es el caso aquí.
Uso de la herencia
Conviene aquí usar la herencia como una técnica buena para definir un concepto de manera incremental, partiendo de las "cosas" generales con menos detalle y seguir entonces con las especificidades y los detalles en las subclases o clases derivadas.
Escribimos entonces el código siguiente:
¿Qué hay de nuevo en el código anterior?
Pues el modificador case para las clases que heredan de Expr
¿Qué efectos tiene añadir el modificador case a una clase?
Son varias las cosas que obtenemos de "gratis" al usar ese modificador. Veamos esas cosas:
- El compilador añade a la clase un objeto acompañante con el nombre de la clase y con un método de fábrica con los mismos argumentos que recibe el constructor primario de la clase. Lo hace para que podamos crear objetos sin hacer uso directo del operador
new
. Con esto nos podemos acercar a una sintaxis más natural del dominio.
Hacer estoEn lugar de esto:
- Todos los argumentos en la lista de parámetros de una clase case obtienen el prefijo val, para poder entonces manipularlos como propiedades:
scala>println("Expr: " +op.asInstanceOf[BinOp].left + " " +op.asInstanceOf[BinOp].operator + " " +op.asInstanceOf[BinOp].right)
Expr: Number(4.0) * Var(x)En una clase "normal", los parámetros de la clase sin el prefijo val NO son propiedades de la clase. Sigue un ejemplo:
scala>class A(arg:String)
defined class A
scala>new A("javamexico").arg
error: value arg is not a member of Ascala>class A(val arg:String)
defined class A
scala>new A("javamexico").arg
res2: String = javamexico
scala>case class A(arg:String)
defined class A
scala>A("javamexico").arg
res3: String = javamexico - El compilador añade implementaciones "naturales" para los métodos
hashcode, toString y equals
. La comparación por igualdad se realiza comparando estructuralmente los elementos de la clase. Recordemos que en Scala, el método==
delega al métodoequals
.
scala>val op = UnOp("-", Var("y"))
op: UnOp = UnOp(-, Var(y))
scala>val op1 =UnOp("-", Var("y"))
op1: UnOp = UnOp(-, Var(y))
scala op == op1
res17: Boolean = true - El compilador añade a la clase un método
copy
para obtener copias modificadas del objeto. Los parámetros del método copy tienen valores de default y también son nombrados. Esto facilita la copia.
El código decopy
para BinOp:def copy(operator=this.operator, left=this.left, right=this.right) = BinOp(operator, left, right)Y su uso:
Después de leer esas bondades de las clases case, uno se pregunta si esas "chuladas" son relevantes. Algunos dirán que sí, otros dirán que no, pero lo relevante no es eso sino la posibilidad de expresar de manera elegante el "pattern matching" en expresiones match y en funciones parciales.
Pero eso lo dejo para la segunda parte de este post.
- bferro's blog
- Inicie sesión o regístrese para enviar comentarios
Comentarios
La segunda parte
La segunda parte la tengo lista mañana
Esta bastante difícil llegar
Esta bastante difícil llegar a una syntaxis que sea al mismo tiempo breve, comprensible y expresiva. Creo que Scala a pesar de lo difícil que parece al principio ( por la extramagia que le pone ) cumple bien el cometido de poder escribir de forma breve algo y que al mismo tiempo no parezca tan criptico. Claro, esta apreciación es totalmente subjetiva y habrá quién piense lo contrario.
Me gustaría saber si el concepto de "case class" esta basado en algo de la PF o si es un invento de Scala? La implementación me queda clara, es como lo que hacen los IDE's con sus macros, más bien me pregunto si eso será algún concepto de la PF
Scala en su naturaleza híbrida logra avanzar en el ámbito de tener clases para modelar el dominio y funciones para trabajar con él. Hay quién opina que este debería de ser llamado un nuevo paradigma de programación llamado Object Functional pero quizá sea demasiado.
Muy interesante el artículo, espero la siguiente entrega.
Solo una aclaración. Es muy común pensar que un DSL lo és solo por parecerse más al lenguaje usado en el dominio o a un lenguaje natural. En realidad no lo es. Un DSL es un lenguaje que pues precisamente solo sirve para un dominio en particular, pero poco o nada tiene que ver son su sintaxis. Un ejemplo es el
Makefile
demake
o elbuild.xml
de ant, son en realidad lenguajes pero que solo sirven para hacer esa tarea que hacen, aunque sean terriblemente difíciles de leer ( los basados en XML en particular ) . Lo contrario son los lenguajes de propósito general, que pueden hacer cualquier cosa. Lo menciono porque en el post se da esa impresión y no es poco común encontrar a varios programadores teniendo la misma impresión de que un DSL lo és porque parece lenguaje natural. El hecho de que Groovy, Ruby y Scala presenten ejemplos de como hacer DSL's cuya principal característica es hacer lo mismo que el lenguaje de propósito general pero sin puntos ( . ) ni parentesis tampoco ayuda mucho.Una excelente presentación en este respecto la podemos encontrar en InfoQ muy bien explicada por Martin Fowler:
http://www.infoq.com/presentations/domain-specific-languages
Saludos.
Los DSL sí deben expresar la sintaxis y la semántica del dominio
El comentario de OscarRyz es muy pertinente.
Los lenguajes específicos de dominio, son lo que el término expresa: específicos del dominio, y para que eso se materialice tienen que atender el lenguaje que usan los especialistas en ese dominio. El lenguaje por tanto tiene que considerar la sintaxis y la semántica de las expresiones que se usan en ese dominio.
Cuando expreso que deben considerar el lenguaje "natural" del dominio, no me estoy refiriendo al lenguaje natural, el que usamos para comunicarnos los seres humanos. Me refiero al lenguaje que es natural en el dominio para escribir las soluciones a los problemas que se desean resolver.
MatLab por ejemplo usa un lenguaje específico de dominio para resolver problemas de matemáticas y otros afines, AutoCad hace los mismo para sus cosas y así podríamos mencionar otros dominios.
Por supuesto que hay dominios particulares para cosas particulares que queremos resolver en el espacio de solución (el espacio de la computadora). Es el caso que menciona OscarRyz cuando hace referencia a lenguajes declarativos usados para la construcción (build) de proyectos. En esos casos las herramientas de construcción ofrecen un lenguaje propio para escribir las "recetas" de construcción.
Con respecto a las clases case y la programación funcional. Sigo en mi otro comentario
Las clases case facilitan el pattern matching
Otra vez, lo que comenta OscarRyz preguntando si las clases case tienen su fundamento en la programación funcional es también pertinente.
Lo que tiene su fundamento en la programación funcional es el pattern matching y las clases case tratan de lograr que el pattern matching sobre objetos se pueda realizar sin mucha dificultad.
Es importante también su uso para escribir funciones parciales que aceptan como argumentos objetos instancias de las clases case.
Sobre eso voy a escribir en la segunda parte de este post
Muy bueno
Ya nomás faltó un ejemplillo de cómo se usan las case classes en el pattern matching, que es cuando ya todo mundo dice "aaaaaaaaah POR EEEESO". Sobre todo para pasar mensajes a actores, pero también para otros casos de pattern matching un poco más complicados. Alguna vez vi un ejemplo para un patrón de números pares y nones, pero no estoy seguro si era una case class o una clase normal, por lo de que hay que implementar el método
unapply
.Espero la siguiente parte, porque eso de las funciones parciales es algo que todavía no termino de entender.
@ezamudio. Los ejemplos llegan hoy
Espero poder escribir hoy la segunda parte. Las clases
case
son buenas, pero no serían tan buenas si no es ocuparan para otras cosas.Esa otra cosa es el pattern matching, técnica obligada en la programación funcional.
Vendrán algunos ejemplos de funciones parciales, que por lo pronto es conveniente recordar que son aquellas funciones que están definidas solamente para "algunos"valores del dominio y no para todos. Cuando tenemos una función que asocia un valor del codominio para cada valor del dominio tenemos entonces una función total.
Scala: Clases case y pattern matching (2/X)
Aquí la segunda parte de este post, donde presentamos el uso de las clases case para hacer pattern matching sobre objetos. Habrán más partes. Ya no me alcanza con dos.
Seguimos el ejemplo de las expresiones aritméticas. Veamos como podemos usar las técnicas de clases case y pattern matching para simplificar expresiones aritméticas.
Consideremos tres reglas de simplificación:
UnOp ("-", UnOp("-", e)) => e
BinOp("+",e, Number(0) ) => e
BinOp("*",e, Number(1) ) =>e
Las propias expresiones para estas tres reglas podrían ser usadas por un algoritmo que determine si una expresión de entrada concreta, por ejemplo
BinOp("+",Var("x"), Number(0) )
se corresponde (hace match) con la parte izquierda de algunas de las reglas de producción anteriores. Podemos pensar que los antecedentes de esas reglas son expresiones "regulares" que pueden contrastarse con esa expresión concreta para determinar si esta última satisface la "expresión regular".Definamos entonces una función parcial para realizar ese pattern matching y un ejemplo simple para hacer uso de ella:
val pf: PartialFunction[Expr, Expr] = {
case UnOp ("-", UnOp("-", e)) => e
case BinOp("+",e, Number(0) ) => e
case BinOp("*",e, Number(1) ) =>e
}
def main(args:Array[String]: Unit = {
val expr = pf(BinOp("+", Var("x"), Number(0) )
println(expr)
val expr1 = pf(BinOp("*", Var("y"), Number(1) ))
println(expr1)
}
Al ejecutar el programa el resultado es:
Var(y)
Como comentamos con anterioridad, una función parcial está definida para un conjunto limitado de los elementos del dominio, que se asocian con un elemento del codominio. En el ejemplo, los elementos del dominio aceptados son aquellas expresiones aritméticas de tipo
Expr+ 0 y Expr * 1
. Para el resto de las expresiones, la función no está definida.PartialFunction
está definido con un trait y un objeto acompañante:Es una función unaria (function1) donde el dominio no incluye necesariamente todos los valores de tipo A. Como toda función, es contravariante en el tipo del argumento y covariante en el tipo de resultado.
Para ella se definen varios métodos útiles para la composición de funciones y por supuesto el método:
que devuelve verdadero si la función está definida para ese argumento o falsa si no está definida:
El resultado sería
false
, mientras que:El resultado sería
true
Con los métodos de PartialFunction podemos resolver problemas interesantes de composición de objetos y de pattern matching. Pero eso se merece otro post.
Podríamos escribir un código similar definiendo un método que use una expresión match:
e match {
case UnOp ("-", UnOp("-", e)) => e
case BinOp("+",e, Number(0) ) => e
case BinOp("*",e, Number(1) ) =>e
case _ => e
}
}
Usamos en este caso el patrón wildcard (_) que hace match con cualquier expresión, pero que no introduce un nombre de variable para poder referirnos a ese valor en la parte derecha de la regla de producción. Esa regla cubre entonces todos los valores que antes se excluían del dominio y deja de ser parcial.
simplifyTop
es un método y no una función pero puede usarse en el lugar que se espera una función gracias a que el compilador realiza la coerción necesaria. Podemos por supuesto hacerlo nosotros cuando lo necesitamos.To be continued .......
Matching objects with Patterns
Para los que quieran profundizar en el pattern matching con objetos el artículo Matching Objects with Patterns está muy bueno.
¿Se acuerdan del post "Clases Case y Pattern Matching?
Este tópico quedó en cierto sentido incompleto. Para completarlo, es necesario continuar con los tipos de pattern matching que Scala soporta, y también hablar de los extractores que generalizan la descomposición y análisis de datos usando pattern matching.
Sigo con el ejemplo de las expresiones aritméticas para describir los tipos de pattern matching. Uso el término en inglés pues los términos en español "apareamiento o casamiento de patrones" no me checan. Cuesta trabajo imaginarse a las estructuras de datos apareándose.
Los tipos de patrones en Scala
Son varios los tipos de patrones que podemos usar en Scala. Una clasificación posible es la siguiente:
La anterior no es la única clasificación que se puede hacer,pero es una conveniente para discutir esta técnica.
Recordemos que cuando hacemos pattern matching utilizamos la expresión match. La sintaxis de esta expresión es:
Recordemos cosas importantes de match
La expresión match es similar a la sentencia switch de muchos lenguajes de programación (Java entre ellos), pero con algunas diferencias:
MatchError
.Cuando creamos una función parcial compuesta por una secuencia de expresiones
case
, podemos usar el métodoisDefinedAt(...)
de las funciones parciales como un mecanismo de programación defensiva para evitar el disparo de la excepción.La secuencia de alternativas de match se escriben como reglas de producción que comienzan con la palabra reservada case:
El símbolo => es también una palabra reservada del lenguaje.
Evaluamos la expresión match evaluando cada alternativa en el orden que aparecen. si el patrón en la la regla hace match con el selector de la expresión, se evalúa entonces la expresión del lado derecho de la regla y su valor es devuelto por la expresión match. Repetimos uno de los ejemplos,donde es evidente que la primera regla se satisface.
case UnOp(x, Var(y)) => println("Es una operación unaria")
case BinOp(x,y,z) => println("Es una operación binaria")
Los patrones wildcard
El patrón wildcard representado por el guión bajo (_) hace match con cualquier valor (objeto). Es útil para expresar el caso de default cuando el resto de las reglas no cubren todos los valores posibles que puede tener el selector de la expresión match. Es una mala práctica aunque útil para propósitos de scripting y depuración cuando trabajamos con instancias de clases case como selectores de una expresión match, y queremos asegurarnos que se incluyen todos los casos posibles, aunque esos casos no han terminado de "programarse".
A veces no podemos hacer eso, debido a que no tenemos un comportamiento de default definido para la expresión match. Necesitamos asegurarnos que las alternativas cubren todos los casos posibles del valor del selector. Las clases selladas (sealed) tienen ese objetivo.
Los patrones wildcard pueden usarse para ignorar partes de un objeto. Por ejemplo, si el selector de la siguiente expresión se asocia con un valor de tipo
Expr
, podemos entonces determinar si se trata de una expresión binaria con:case BinOp(_,_,_) => println (selector + " es una expresión binaria")
case _ =>println(selector + " es otro tipo de expresión")
}
¿Qué sucedería en el siguiente código?
case BinOp(_,_,_) => println (selector + " es una expresión binaria")
case _ =>println(selector + " es otro tipo de expresión")
}
Los patrones constantes
Un patrón constante hace solamente match consigo mismo. Como constantes en el patrón podemos usar cualquier literal, cualquier val y cualquier objeto singleton. El siguiente ejemplo muestra el uso de algunos patrones constantes:
def wtf(arg: Any): Any = {
arg match {
case 2 => println("Matching perfecto con 2: " +arg); arg
case Solitario => println("Matching perfecto con Solitario: " +arg);arg
case Nil => println("Matching perfecto con List() o Nil: " +arg);arg
//case List() => Nil
case true => println("Matching perfecto con true: " +arg);arg
case "hola" => println("Matching perfecto con 'hola': " +arg);arg
case _ => println("Matching predeterminado con: " +arg);arg
}
}
El ejemplo muestra el uso de literales y de dos objetos: Solitario, definido en el ejemplo y Nil, parte de Scala.
Si ejecutamos:
Matching perfecto con Solitario: Solitario
res x = Any = Solitario
scala>
Observa que Solitario está definido como un case object, de manera que obtenemos de "gratis" su representación textual sin necesidad de definir el método
toString()
. El compilador lo hace por nosotros.Siempre suceden cosas "extrañas". Se me ocurrió escribir el siguiente código,que compila sin problemas:
arg match {
case Nil => println("Nil was passed to arg")
case List() => println ("List() was passed to arg")
case _ => println("otherwise")
}
}
Y después de escribirlo y entrarme algunas dudas sobre List() y Nil para indicar listas vacías, lo reescribí de la siguiente forma:
arg match {
case List() => println ("List() was passed to arg")
case Nil => println("Nil was passed to arg")
case _ => println("otherwise")
}
}
Y entonces ese código no compiló indicando la línea
case Nil => .....
como "unreachable code".Puedes echarle un vistazo a algunas opiniones sobre eso en StackOverFlow.
Patrones variables
Un patrón variable hace match con cualquier objeto o valor. Entonces, ¿cuál es la diferencia con el patrón wildcard que también hace match con cualquier objeto?
La diferencia es importante: Scala hace binding de la variable con lo que sea el objeto y en la parte derecha de la regla podemos hacer uso de ella para "interactuar" con el objeto.
El método
simplifyTop(expr:Expr)
del ejemplo de expresiones aritméticas que estamos usando ilustra esa asociación entre el patrón variable y la parte correspondiente del selector:expr match {
case BinOp("+", expr, Number(0)) => expr
case BinOp("*", expr, Number(1)) => expr
case UnOp("-", UnOp("-", expr)) => expr
case _ => expr
}
}
En el código se desea simplificar expresiones binarias convirtiendo
var + 0
envar
y convirtiendovar * 1
envar
. Se simplifica también un expresión unaria de tipo- - var
envar
.La variable
expr
del patrón variable se asocia con la parte correspondiente de la representación del objeto.Sigue otro ejemplo, que por supuesto jamás escribiríamos para obtener el principio y el resto de una lista, pero que ilustra los patrones variables:
arg match {
case ::(x,xs) => format ("El principio de la lista es %s y el resto %s ", x, xs)
case Nil => format ("%s es una lista vacía",arg)
}
}
scala> wtf(List(List(1,2,3),"a", "b", "c"))
res14:String= "El principio de la lista es List(1,2,3) y el resto List(a,b,c) "
scala>wtf(Nil)
res15: String = "List(0) es una lista vacía
Si cambiamos el patrón del primer case del ejemplo anterior por
x::xs
, teniendo ahora el siguiente código:arg match {
case x::xs=> format ("El principio de la lista es %s y el resto %s ", x, xs)
case Nil => format ("%s es una lista vacía",arg)
}
El resultado es el mismo. Conviene saber la diferencia entre
x::xs
y::(x,xs)
. Lo dejo de tarea.Nota: El método
format( text; String, xs: Any*)
en el objetoscala. Predef
está "deprecated", pero es irrelevante en estos ejemplos.¿Cómo identificar los patrones variables de los patrones constantes?
Scala necesita una regla léxica para distinguir las constantes o valores de las variables en los patrones variables y los patrones constantes. El siguiente código no compila:
arg match {
case A =>println("wtf se invoca con el valor A")
case _ =>println("wtf se invoca con otra cosa")
}
}
El eror se anuncia como:
case A =>println("wtf se invoca con el valor A")
Si ahora escribimos:
def wtf(arg: Any) = {
arg match {
case A =>println("wtf se invoca con el valor A")
case _ =>println("wtf se invoca con otra cosa")
}
}
wtf(A)
wtf(4)
wtf(5)
el código compila y se obtiene el resultado:
wtf se invoca con el valor A
wtf se invoca con otra cosa
La regla léxica entonces es: utilizar nombres que comiencen con una letra minúscula para patrones variables y nombres que comiencen con mayúscula para patrones variables.
El libro de Odersky ilustra esa regla con los valores Pi y E definidos en el objeto paquete scala.math
E match {
case Pi=> "strange math? Pi= " +Pi
case _ =>"OK"
}
¿Cuál es el resultado?
OOPS, ¿Qué es un objeto paquete (package object)? Lo dejo de tarea.
¿Qué sucede si escribimos los siguiente?
E match {
case pi => "strange math? Pi = " +pi
}
¿Cual sería el resultado?
Apenas he descrito los patrones variables y los patrones constantes y ya se hizo muy largo este post. Mejor le paro aquí y sigo con este rollo en otra entrada.
Continuación de clases case y pattern matching
Ayer escribí esto para los interesados. Pienso platicar de pattern matching en Scala en el próximo open talks.
Eso!
Qué bien, me parece un buen tema para las opentalks