Typeclasses

Cuando se lee el término “typeclasses” por primera vez, causa confusión debido a que pensamos o en tipos o en clases, pero no en un término compuesto por ambas palabras. No obstante, el concepto es simple y muy poderoso, y seguramente quienes tienen experiencia con Java o lenguajes similares habrán usando algo similar sin saberlo.

Una typeclass es como una “etiqueta” que define el comportamiento que sus miembros deben seguir para ser considerados parte de ella. ¿Difícil? Suena a que sí, pero el concepto es similar a las interfaces en Java.

Por ejemplo: hace tiempo escribí sobre encontrar la diferencia en kanji usando Haskell, y ahí mencionaba que gracias a las typeclasses el algoritmo se podía generalizar para cualquier tipo de datos que fuera miembro de Eq y Show. Veamos el código en cuestión:

printUniques :: (Show a) =>  [(a,Int)] ->  IO ()
printUniques [] = putStrLn "All instances are the same"
printUniques us = mapM_ putStrLn $ map (\c →  ((("Unique instance: " ++ ) $ show $ fst c) ++ ) $ ((" in position " ++ ) $ show $ (+1) $ snd c)) us
 
countInstances :: (Eq a) =>  a ->  [a] ->  Int
countInstances _ [] = 0
countInstances c (x:xs)
    | c == x = 1 + countInstances c xs
    | otherwise = countInstances c xs

(El código completo está en el artículo mencionado).

Hay 2 funciones aquí: printUniques y countInstances. Veamos la declaración de la segunda:

countInstances :: (Eq a) =>  a ->  [a] ->  Int

La función recibe 2 parámetros: uno de tipo a, uno de tipo lista de a ([a]) y regresa un entero, pero tiene la restricción de que el tipo a tiene que pertenecer a la typeclass Eq; de otra forma, el compilador nos dirá que estamos pasando algo incorrecto. En este caso, Eq es la typeclass que define la forma de decidir si 2 elementos de un mismo tipo son iguales. Y de la misma forma, printUniques requiere que el tipo de datos sea miembro de Show, la typeclass que define cómo se mostrará en pantalla un elemento.

En los ejemplos del artículo se aprecia que el código funciona para enteros, cadenas y listas (entre otros), ya que todos esos tipos son miembros de las typeclasses requeridas.

Como podrán notar, una typeclass suena muy similar a una interface en Java: podemos definir operaciones y funciones basadas en interfaces y no en tipos y así se puede generalizar el código. Entonces, ¿qué tienen de interesantes las typeclasses con respecto a las interfaces? En resumen: que podemos agregar a una typeclass un tipo de dato ya existente sin tener que redefinirlo (recordar que en Java hay que declarar a clase con “implements X, Y, Z” para decirle que usará esas interfaces, y si el tipo de datos es final, no tenemos opción).

En Scala, definir una typeclass se hace:

  • Definiendo un trait (equivalente a una interface en Java)
  • Definiendo un companion object que provea, de forma implícita, implementación de las funciones declaradas en el trait para cada uno de los tipos deseados (algo así como un default).

Un companion object es un objecto que tiene el mismo nombre que una clase o un trait, y provee funciones que trabajan sobre ese tipo. Sería algo así como una static class en Java, con métodos estáticos.

class MyClass(val a: String, val b: Int) {
  // Métodos de instancia
  def calculateSomething(j: String) = /// implementación
  def jaja = println("jajaja" + a)
}

// Companion Object
object MyClass {
  /* Apply permite crear instancias sin usar new.
     Ejemplo:
     val x = MyClass("x",1)
   */
  def apply(a: String, b: Int) = new MyClass(a,b)

  /*
    Esta función se llama así:

    MyClass.myClassFactory(3)
  */
  def myClassFactory(a: Int) = {
   if (a <= 0) 
     MyClass("menor o igual a cero", a)
   else
     MyClass("mayor a cero", a)
  }
}

Mucho verbo. Mejor veamos lo anterior con un ejemplo, el cual fue tomado y modificado de aquí. Supongamos que queremos crear una función que permita sumar, restar o dividir elementos de una clase. Estas operaciones no necesariamente tienen que en clases de números (como Int o Double). En vez de limitar esos usos, mejor creamos una typeclass, implementamos las operaciones en cada tipo de datos y definimos la función en términos de la typeclass. En resumen: queremos funciones que permitan hacer que 2 instancias del mismo tipo de datos puedan ser combinadas con las mismas operaciones, algo como:

sumar(1,2)

sumar(1.4,5.6)

restar((“jajaja”,12), (“jajajo”, 15))

restar((123,”290129″), (488,”1920192″))

Definamos entonces un objeto que envuelva lo que necesitamos para la typeclass. El objeto se llamará Math, y la typeclass “NumberLike”. Noten que los objetos se declaran como implícitos.

object Math {
 trait NumberLike[T] {
   def sumar(a: T, b: T): T
   def restar(a: T, b: T): T
   def dividir(a: T, b: Int): T
 }

  // Companion object

  object NumberLike {
     /* Ahora a agregar implementaciones para varios tipos.
        Noten que los objetos están definidos como implícitos
        para evitar tener que escribirlos cada vez que llamamos
        a una de estas implementaciones

     */
    implicit object IntLikeNumber extends NumberLike[Int] {
      def sumar(a: Int, b: Int) = a + b
      def restar(a: Int, b: Int) = a - b
      def dividir(a: Int, b: Int) = a / b
    }

    /*
      Definimos las operaciones de tuplas (String, Int)
      como el primer elemento en a y la aplicación de la operación 
      indicada en el segundo elemento de a y b
    */
    implicit object SITupleLikeNumber extends NumberLike[(String, Int)] {
      def sumar(a: (String, Int), b: (String, Int)) = (a._1, a._2 + b._2)
      def restar(a: (String, Int), b: (String, Int)) = (a._1, a._2 - b._2)
      def dividir(a: (String, Int), b: Int) = (a._1, a._2 / b)
    }
  }

}

Aquí hemos implementado los métodos para incluir a Int y a la tupla (String, Int) en la typeclass NumberLike, pero si queremos agregar cualquier otro tipo de datos solamente necesitamos agregar el objeto  que implemente las operaciones requeridas.

¿Qué ventajas nos da un código así? Que podemos tener algo como esto:

def calculateDifference[T](a: T, b: T)(implicit nlike: NumberLike[T]) = {
   nlike.restar(a,b)
}

def main(a: Array[String]): Unit = {
  val a = ("jojo", 143)
  val b = ("jaja", 43)
  val c = 4343
  val d = 343

  /* El argumento implícito permite que las
     llamadas de ambos tipos sean exactamente iguales,
     lo que se traduce en "poliformismo ad hoc"
  */
  println(calculateDifference(a,b))
  println(calculateDifference(c,d))
}

Gracias que calculateDifference declara como implícito el segundo argumento, Scala va y busca en el scope por un argumento válido, y como en este caso ya uno ha sido proveído en el companion object, lo utiliza. La primera llamada usa NumberLike[(String, Int)], mientras que la segunda NumberLike[Int].

Esta idea de generalizaciones es muy poderosa, siempre y cuando sepamos abstraer bien lo que queremos hacer. Hay veces en que no es necesario generalizar tanto un código, mientras que en otras ocasiones podríamos generalizar una idea para reutilizarla después, pero no lo hacemos porque no abstraemos del todo lo que queremos hacer.

Un ejemplo específico, extraído del anterior: la idea de sumar elementos de un mismo tipo bien podría abstraerse de tal forma que cuando necesitemos sumar o reducir una colección de ese tipo, en vez de crear código similar para cada uno, se tenga una etiqueta que asegure que el tipo de datos recibido implementa una función llamada “sumar”, y que por tanto no debemos preocuparnos de cómo está implementada y simplemente la llamamos.

En NumberLike incluímos implementación de “sumar” para Int y para (String, Int), pero podríamos agregar para otros tipos específicos, como Option:

implicit def OptionLikeNumber[T](implicit ev: NumberLike[T]) =  new NumberLike[Option[T]] {
   def sumar(a: Option[T], b: Option[T]) = {
     if (!x.isDefined) y
     else if (!y.isDefined) x
     else {
          for {
            cx <- x
            cy <- y
          } yield (ev.sumar(cx,cy)) // Sumar el tipo de datos con su propia implementación
     }
  }


   // Implementar los restantes restar y dividir
}

def suma[T](a: T, b: T)(implicit nLikeT: NumberLike[T]) =
   nLikeT.sumar(a,b)

def reduce[T](l: List[T])(implicit nLikeT: NumberLike[T]) =
   l reduce nLikeT.sumar

val x: Option[Int] = Some(3)
val y: Option[Int] = Some(1)
val z: Option[Int] = Some(6)

// Regresa Some(4)
suma(x, y)

// Regresa Some(10)
reduce(List(x,y,z))

/* Usando el código definido arriba para Int y (String,Int) */
// Regresa 6
 reduce(List(1,2,3))

// Regresa ("ja",234)
reduce(List(("ja",1),("jo",34),("ju",199))

Aquí, en vez de usar un objeto, creamos una función que regresa un NumberLike[Option[T]], con la restricción de que el tipo T tiene que pertenecer a la typeclass NumberLike. Esto es debido a que Option es un tipo que espera un parámetro al ser definido; además, sirve como ilustración de que un método también sirve para definir directamente la implementación de una typeclass. La implementación la definimos como:

  • None + Some(x) = Some(x)
  • Some(x) + None = Some(x)
  • Some(x) + Some(y) = Some(NumberLike[TipoDeDatosDe x,y].sumar(x,y))

Y así podemos seguir definiendo sumar, restar y dividir para cualquier tipo de datos, y las funciones suma y calculateDifference harían uso de esas implementaciones sin que su lógica cambie.

Programar con base en typeclasses y no en tipos de datos específicos implica más abstracción de lo normal, pero al mismo tiempo provee generalizaciones de código que pueden ser usadas en muchos lugares sin necesidad de cambios.

Es importante recordar el concepto de typeclasses, ya que los siguientes temas (Semigrupos y Monoids) son precisamente typeclasses que definen cierto comportamiento común entre muchos tipos de datos.