Ir al contenido
  1. Posts/

Qué es List cuando no es un tipo

·6 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida

Int es un tipo. String es un tipo. List[Int] es un tipo. List no es un tipo.

Esa frase suele irritar un poco la primera vez que aparece. Suena a truco. Suena a una de esas afirmaciones que alguien suelta en una charla para parecer más profundo de la cuenta. Porque, siendo honestos, List está en todas partes. La usas a diario. ¿Cómo que no es un tipo?

Pero escribe esto en el REPL de Scala y mira qué pasa:

val x: List = ???
// error: List takes type parameters

Lo que está pasando es menos raro de lo que parece. List por sí sola no está terminada. Le falta información. Necesita un type argument para convertirse en algo que puedas usar como tipo. List es, dicho de forma muy poco solemne, una máquina de fabricar tipos. Le das Int y te devuelve List[Int]. Le das String y te devuelve List[String]. Si no le das nada, la máquina está ahí, pero todavía no ha producido un tipo completo.

A esa clase de cosa se le llama type constructor.

qué-es-list-cuando-no-es-un-tipo-img-7.svg

El nivel por encima de los tipos
#

Para entender bien un type constructor, ayuda volver un momento a lo básico. ¿Qué hace realmente un tipo? Clasifica valores. Int clasifica el valor 42. String clasifica "hello". List[Int] clasifica List(1, 2, 3). Los tipos viven un nivel por encima de los valores.

Los type constructors viven un nivel por encima de los tipos. No clasifican valores. Clasifican, o mejor dicho, transforman tipos. List toma un tipo y produce un tipo. Option toma un tipo y produce un tipo. Either toma dos tipos y produce un tipo.

Y ese nivel por encima de los tipos tiene su propio vocabulario: kinds. La forma más compacta de decirlo es esta: un kind es a un tipo lo que un tipo es a un valor.

Un proper type como Int o String tiene kind * (se lee “star” o, si quieres pensarlo de forma menos ceremonial, “tipo completo”). Es un tipo que ya puede clasificar valores directamente.

Un type constructor como List tiene kind * -> *. Toma un tipo de kind * y produce otro tipo de kind *. La notación con flecha se parece mucho a la de las funciones normales, y esa semejanza no es casual: Int -> String es una función de valores a valores; * -> * es una “función” de tipos a tipos.

Either tiene kind * -> * -> *. Necesita dos type arguments para convertirse en un proper type. Map va por el mismo camino.

Por qué esto importa de verdad
#

La mayor parte del tiempo no piensas en los kinds, y no pasa nada. Scala los infiere por ti y el compilador protesta con bastante energía cuando algo no encaja. Pero llega un momento en el que dejan de ser una curiosidad teórica y se vuelven inevitables: cuando quieres escribir código que abstrae no sobre tipos concretos, sino sobre familias de tipos.

Functor es el ejemplo clásico. Un functor es, simplificando un poco, algo sobre lo que puedes hacer map. List, Option y Either[String, _] entran en esa categoría. Y tarde o temprano quieres escribir una abstracción que funcione para cualquiera de ellos:

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

Fíjate en F[_]. Esa es la forma que tiene Scala de decir: “F es un type constructor que espera un type argument”. Ya no estás abstrayendo sobre un proper type como Int. Estás abstrayendo sobre algo como List u Option. Functor[F[_]] tiene kind (* -> *) -> *: toma un type constructor de kind * -> * y produce un proper type.

A eso se le llama higher-kinded type: un tipo parametrizado por un type constructor en vez de por un proper type. El nombre impone más de lo que ayuda, pero la idea es simple: has subido un escalón en el nivel de abstracción.

Viéndolo en criteria4s
#

Si has leído la serie de criteria4s, ya has visto higher-kinded types funcionando en código real, aunque quizá todavía no les hubieras puesto nombre.

Show es un binary type constructor:

trait Show[-V, D <: CriteriaTag] {
  def show(v: V): String
}

Show por sí solo no es un tipo completo. Show[Int, SQL] sí lo es. Show tiene kind * -> * -> *.

BuilderBinary es higher-kinded:

trait BuilderBinary[H[_ <: CriteriaTag]] {
  def build[T <: CriteriaTag](F: (String, String) => String): H[T]
}

H[_ <: CriteriaTag] dice: H es un type constructor que toma un type argument, con la condición de que ese argumento sea subtipo de CriteriaTag. Cuando escribes BuilderBinary[EQ], estás pasando el type constructor EQ como argumento para H. EQ por sí solo no es un tipo completo: EQ[SQL] es un tipo, EQ[MongoDB] es un tipo, pero EQ a secas es un type constructor de kind * -> * restringido a subtipos de CriteriaTag.

En la práctica, se lee mucho más fácil de lo que parece: BuilderBinary sabe construir instancias para cualquier type class de predicado, siempre que esa type class esté parametrizada por un dialecto. Suena aparatoso porque el type system está describiendo algo muy general, pero la idea sigue siendo la misma: estamos abstrayendo un nivel más arriba.

Cómo se escribe esto en Scala 3
#

Llegados a este punto, la sintaxis de Scala 3 deja de parecer un jeroglífico. [F[_]] significa “F es un type constructor con un type parameter”. Y sí, puedes añadir restricciones:

// F toma un type parameter, sin restricción
def example[F[_]](...)

// F toma un type parameter que debe extender CriteriaTag
def example[F[_ <: CriteriaTag]](...)

// F toma dos type parameters
def example[F[_, _]](...)

También verás F[_] en context bounds o en type aliases cuando alguien quiere nombrar una familia concreta de type constructors. El _ actúa como placeholder: “aquí hay un type parameter, pero ahora mismo no necesito ponerle nombre”.

La versión corta
#

  • Un proper type (Int, List[Int]) tiene kind *. Clasifica valores.
  • Un type constructor (List, Option) tiene kind * -> *. Toma un tipo y produce un tipo.
  • Un higher-kinded type es un tipo parametrizado por un type constructor, escrito F[_] en Scala 3.
  • Los kinds clasifican tipos, igual que los tipos clasifican valores.

No necesitas pensar en los kinds todos los días. Pero cuando te encuentres un F[_] en la definición de un trait, o cuando el compilador diga type constructor expected but type found, dejará de sonar a mensaje esotérico. Ya sabrás qué te está intentando decir. Y, con un poco de suerte, también por qué List a veces no es un tipo.