Sistemas de Tipos: Más allá de Java y C#

Me ha tocado recientemente refactorizar componentes de una aplicación que tiene parte en C# y parte en Javascript, y como era de esperar, el sistema de tipos estático de C# ha supuesto una ayuda, lo que me ha llevado a preguntarme hasta dónde podría ayudarme un sistema de tipos más potente (como el de Haskell, por ejemplo).

Al hilo de esto, creo que merece la pena dar a conocer lo que puede hacer por ti un sistema de tipos más allá de la clásica guerra entre tipado estático y dinámico. Hoy en día muchos desarrolladores acostumbrados a lenguajes como C# o Java tienen que tratar con Javascript, y percibir las diferencias entre el tipado estático y dinámico resulta fácil, pero también es cierto que muchos de ellos desconocen lo que se puede hacer con sistemas de tipos más potentes, por lo que creo que es una buena excusa para dedicarle un post a este tema.

OJO: No soy un experto en sistemas de tipos (para eso tengo a mis expertos de cabecera), por lo que la exposición que voy a hacer en este post en un poco de andar por casa. Con suerte, te servirá para interesarte por las posibilidades que ofrecen otros lenguajes.

Aclarando un par de conceptos

Antes de empezar, vamos a aclarar un par de puntos:

  • Tipado estático frente a tipado dinámico: este es un concepto relativamente fácil de entender. En un lenguaje con tipado estático, el tipo de una variable o función queda definido en el momento de declararla y no puede ser modificado, mientras que en un lenguaje dinámico, este tipo puede cambiar durante la ejecución del programa. Los lenguajes con tipado estático permiten realizar comprobaciones de tipos en tiempo de compilación, lo que ayuda a detectar errores antes, mientras que los lenguajes de tipado dinámico ofrecen (en teoría) más flexibilidad en estados intermedios del desarrollo porque nos permite generar programas parcialmente erróneos (o indefinidos) pero ejecutables (Javier Neira dixit).
  • Tipado fuerte frente a tipado débil: en un lenguaje con tipado fuerte, en general, no es posible realizar conversiones entre tipos, o al menos no es tan sencillo convertir de unos tipos a otros a menos que se hayan definido las conversiones explícitamente. En los lenguajes con tipado débil se realizan conversiones de unos tipos a otros de forma más automática, lo que permite escribir código más terso a costa de arriesgarnos a cometer errores motivamos por conversiones inesperadas.

Es importante tener en cuenta que ambos ejes son ortogonales. Podemos tener lenguajes estáticos con tipado fuerte, como C# y lenguajes estáticos con tipados más débiles, como C, donde podemos convertir casi cualquier cosa en un booleano o cambiar de un tipo a otro mediante casts a void*. En el otro extremo, tenemos lenguajes dinámicos de tipado débil, como Javascript, en donde casi cualquier tipo se puede convertir a otra cosa, dando lugar a situaciones curiosas (podéis empezar a jugar con el operador +, strings y números y verlo vosotros mismos), pero también tenemos lenguajes dinámicos con tipado fuerte, como clojure, donde no hay conversiones implícitas entre distintos tipos de datos (aunque, al ser un lenguaje dinámico, los errores los obtendremos en tiempo de ejecución).

Ahora que tenemos clara esta parte, vamos a ver algunas de las características que podemos encontrar en otros sistemas de tipos pero que no están disponibles en lenguajes como C# o Java.

No más null

Empezamos por lo más básico, el error del billón de dólares. Parece razonable que alguien se haya preocupado de evitar este error desterrando (o al menos controlando) el temible null. En lenguajes como Haskell no existe el concepto de null, o al menos no existe como un valor válido para todos los tipos, cosa que sí ocurre en C# (excepto para structs) y en Java (excepto para tipos primitivos).

Es cierto que tanto Java como C# cuenta con facilidades para tratar con null, desde el uso del tipo Maybe /Optional, hasta operadores como ?. en C# que ayudan a lidiar con null, pero no es lo mismo y tienen sus propios problemas.

Existen lenguajes, como por ejemplo Kotlin o Swift, en los cuales puedes declarar una referencia como no nullable, y el compilador se encarga de garantizar que nunca, jamás, contendrá un valor null.

Por ejemplo, en Kotlin tendríamos:

// Por defecto, las variables no puede valer null
val name : String = "Lucas"
name = null // esto genera un error de compilación

// Los tipos nullables, obviamente, permiten null
val maybeName : String? = null

//... pero esto genera un error de compilación porque
// el valor podría ser null
maybeName.length()

// ...necesitamos asegurarnos de que el valor no es null
// antes de invocar el método para que el programa compile 
val length = if (maybeName != null) maybeName.length() else -1

// ... o propagar el posible null: maybeLength tiene tipo Int?
var maybeLength = maybeName?.length() 

Tipos algebraicos

Los tipos algebraicos nos permiten definir tipos suma y tipos producto.

Un tipo suma consiste en una enumeración de distintos valores posibles para un tipo. A simple vista, podría parecer que es equivalente a un tipo enumerado, pero resulta más potente porque los valores enumerados puede contener “parámetros” (en realidad, cada caso de la enumeración es un constructor).

Por ejemplo, en Haskell podemos definir la información de contacto de una persona como:

data ContactInfo = None
                 | Phone String
                 | Email String

De esta forma, indicaríamos que la información de contacto puede no ser ninguna (None), un número de teléfono con el número concreto (Phone String) o un email con la dirección concreta (Email String).

En general, esto suele ir asociado con pattern matching que nos permite trabajar cómodamente con los tipos suma y extraer la información almacenada en ellos:

send :: ContactInfo -> String -> IO()
send None msg = putStr "No hay forma de contactar"
send (Phone number) msg = sendSms  number msg
send (Email address) msg = sendEmail address msg

Los tipos producto nos permiten construir un producto cartesiano entre los valores de dos tipos distintos. El caso más frecuente son las tuplas, que están formadas por una serie ordenada de valores de tipos concretos y que, nuevamente, encajan muy bien con la filosofía del pattern matching.

En lenguajes como Java o C# es fácil simular tanto tipos suma como tipos producto, pero la forma de tratarlo resulta mucho menos cómoda que en lenguajes que están preparados para ellos y suelen requerir bastante más código, tanto para definirlo como para tratarlos posteriormente. Un ejemplo de esto son los tipos enumerados de Java que podemos también simular en C#.

Tipado estructural

Todos los que hemos trabajado con lenguajes como Javascript conocemos el Duck Typing. Es una idea sencilla, si tenemos un objeto sobre el que podemos realizar ciertas operaciones, nos da igual el tipo real que tenga, sólo nos importa que podemos realizar esas operaciones: si camina como un pato y grazna como un pato, es un pato.

En los lenguajes como C# o Java, para conseguir algo parecido necesitamos recurrir a interfaces, y eso nos obliga a definir a priori de qué formas podemos ver el tipo que estamos definiendo. Si tengo un tipo que implementa un método void Serialize(XmlWriter writer), pero no implementa el interface ISerializer, no podremos usarlo como tal, aunque tengamos un método perfectamente válido para ello.

El tipado estructural consisten en poder aplicar la misma lógica, pero en tiempo de compilación. Existen otros lenguajes, como Go y Scala que son capaces de hacer esto, por lo que no es tan necesario definir a priori los “interfaces” que implementa un tipo. Mientras tenga métodos compatibles con lo que necesitamos, servirá, y en caso de que no los tenga, el compilador nos avisará.

Por ejemplo, en scala podemos tener la siguiente función para calcular el área de prismas regulares:

def getVolume(prism: { def getBaseArea(): Int; def getHeight(): Int }) : Int = {
  prism.getBaseArea() * prism.getHeight()
}

class Cube(val s: Int) {
  val size = s
  def getBaseArea() : Int = size * size
  def getHeight() : Int = size
}

val c = new Cube(5)
val v = getVolume(c)
println("volume is %d".format(c))

Al definir la función, indicamos que el parámetro prism debe tener métodos para obtener el área de la base y la altura, pero no obligamos a que implemente ningún interfaz o herede una clase concreta. Cualquier objeto que exponga esos dos métodos, por ejemplo una instancia de la clase Cube podrá ser utilizado como argumento al invocar la función.

Type Classes y Protocolos

El concepto de Type Classes de lenguajes como Haskell y, en menor medida, los procolos de lenguajes como Clojure, permiten una forma de polimorfismo muy potente y flexible.

Tenéis una buena explicación en el post sobre protocolos en Clojure, pero para simplificar, la idea es que puedes definir un interface y su implementación para tipos ya existentes, sin necesidad de modificar la declaración de los tipos, y a partir de ese momento, siempre que necesites una instancia de ese interface, puedes utilizar una instancia de esos tipos, como si lo hubiesen implementado desde un principio.

Por ejemplo, en Haskell, podemos definir la clase de los tipos cuya área se puede calcular y hacer que tipos definidos previamente “implementen ese interfaz”:

-- Este es un caso claro para usar un tipo suma, pero para el ejemplo
-- vamos a dejarlo como tipos completamente independientes

data Square = Square Float Float
data Circle = Circle Float

class Area a where
    area = a -> Float

instance Area Square where
    area (Square width height) = width * height

instance Area Circle radius where
    area (Circle radius) = pi * radius * radius

En el momento de definir los tipos Square y Circle no hemos indicado para nada que tendrán pertenecerán a la clase de tipos Area, pero podemos hacerlo a posteriori.

Nuevamente se trata de algo que podemos resolver en lenguajes como Java o C# recurriendo a patrones de diseño clásicos como adapter, pero resulta más incómodo y menos flexible.

Tipos dependientes

Esto es algo bastante más avanzado que no muchos lenguajes implementan. El más popular posiblemente sea Idris, y seguramente muchos no hayáis oído hablar de él en la vida.

Hoy en día muchos de los lenguajes más populares, entre ellos C# y Java, soportan tipos genéricos, que nos permite tener “tipos de orden superior” a partir de los cuales construir otros tipos. Podemos tener un tipo List<T> que luego podemos parametrizar para construir el tipo List<int> o List<string>.

Una limitación de esto es que sólo podemos tener tipos que dependen de otros tipos, pero no que dependan de valores. Los tipos dependientes (y aquí invoco a los expertos, porque hay partes que se me excapan), permiten definir tipos que dependen de valores concretos y no sólo de otros tipos.

Para no complicarlo mucho (podéis ver un ejemplo real en Idris) vamos a ver un poco las implicaciones de esto. Si tenemos un tipo Vector en el que indicamos, en el momento de su construcción, la longitud y el tipo de elementos, por ejemplo Vector 10 Int para indicar un vector de 10 elementos de tipo Int, podríamos declarar una función con una signatura parecida a ésta:

concat :: Vector n a -> Vector m a -> Vector (n + m) a

Es decir, en la propia signatura de la función estamos codificando que, si concatenamos dos vectores de longitudes n y m, ambos con elementos de tipo a, el vector resultante tendrá que tener, necesariamente, longitud n + m y contener elementos de tipo a.

Si lo pensáis un poco, la potencia de un sistema de tipos de estas características es enorme, porque podemos incluir muchos de los invariantes de las operaciones en la propia definición de los tipos de las funciones.

La parte mala es que estos lenguajes de programación llevan asociada una gran complejidad que hace que aprender a manejarse con ellos y sacarles partido no sea una tarea trivial.

Conclusión

Como decía al principio, este post no pretende ser una introducción seria a lo que son los sistemas de tipos, y tampoco se trata de un listado exhaustivo de lo que pueden hacer otros lenguajes, pero creo que puede ayudar a hacerse una idea de lo que se puede conseguir con otros sistemas de tipos.

Todos tienen su parte buena y su parte mala, e igual que hay mucho (pero mucho) software construido con lenguajes dinámicos como Ruby, PHP, Python o Javascript, se puede trabajar perfectamente con sistemas de tipos más simples como el de C# o Java sin necesidad de disponer de todas estas herramientas más potentes.

A veces parece tentador añadir a nuestro lenguaje favorito estas características, igual que se ha hecho con C# añadiendo cosas como dynamic o en scala o kotlin añadiendo casi todo lo que se les ha ocurrido, pero la featuritis en lenguajes de programación es un problema como ya comentamos en su día.

También es importante tener en cuenta que esto más allá de la sintaxis. No se trata simplemente de que en unos lenguajes resulte más o menos cómodo hacer cierto tipo de construcciones (que también es importante), sino de que hay cosas que, directamente, no se pueden hacer en determinados lenguajes sin reimplementar gran parte de otros sistemas de tipos.

9 comentarios en “Sistemas de Tipos: Más allá de Java y C#

  1. Tengo que empezar con un «disclaimer»: me temo que aunque haya estudiado lenguajes con sistemas de tipos avanzados como haskell, no tengo experiencia real en desarrollar sistemas complejos en ese lenguaje (y menos en idris). Por tanto sigo siendo un aprendiz en estos temas. Aun asi me congratulo de que el diario de mi aprendizaje pueda ser de ayuda ;-)

    En cuanto a la entrada tengo que decir que es una buena introduccion aunque echo de menos un aspecto de los sistemas de tipos modernos: la inferencia. Uno de los aspectos negativos de los tipos estaticos que se suele señalar es que obliga al programador a anotar, a veces de forma abrumadora las declaraciones del lenguaje (Customer customer=new Customer() y tal). Los lenguajes con sistemas de tipos modernos no necesitan, en la mayoria de los casos (unos mas que otros), anotar los tipos con lo que se consigue la claridad sintactica de los lenguajes dinamicos. Normalmente se recomienda anotar las declaraciones superiores pero estan suelen ser opcionales.
    En cuanto a la otra parte, el tipado dinamico habria que remarcar que su gran ventaja es que permiten escribir programas parcialmente erroneos (o indefinidos) pero ejecutables. Esto no parece una ventaja y en la mayoria de los casos no lo es pero si puede serlo en mitad de desarrollo, cuando no se tiene muy claro cuales son esos invariantes que los tipos estaticos ayudan a reforzar. Lo que es erroneo es una fase del desarrollo no tiene por que serlo en una mas avanzada o simplemente se puede quere interactuar con el programa sin que este este completo. Esas invariantes se traducen en dependencias entre modulos que tienen que ser completamente correctas para poder ejecutar el programa completo.
    Los tipos estaticos te obligan a pensar a priori esas invariantes y a veces hacerlo en un estadio temprano del desarrollo puede ser contraproducente.
    Casi todos los lenguajes permiten vias de escape para permitir programas parcialmente correctos (incluido haskell) pero esta claro que es mas complejo de conseguirlo.

  2. Gracias por la (como siempre) acertada aportación.

    Tienes razón en lo de la inferencia de tipos, es algo que ayuda mucho a conseguir un código más limpio, fácil de escribir e incluso refactorizar. De hecho incluso en sus casos más simples y limitados, como la inferencia de argumentos para tipos genéricos en C#, supone una gran ayuda que se nota especialmente cuando no funciona y tienes que hacerlo explícito (p.ej. en los tipos de retorno).

    Sobre los lenguajes dinámicos, me gusta lo de «escribir programas parcialmente erróneos pero ejecutables», espero que no te importe que lo incluya en el post. Es una forma interesante de verlo, precisamente por los motivos que tú mismo comentas (estados exploratorios intermedios).

    En ese sentido, ¿cómo ves los sistemas de tipado gradual?

    Estoy pensando (como ya te imaginas) en Clojure y su core.typed, pero también en lenguajes como TypeScript. En teoría, con un lenguaje así podrías aprovechar las ventajas del tipado dinámico durante la fase de mayor incertidumbre y, una vez que tienes más claros los invariantes, «sujetarlos» con definiciones de tipos.

  3. Se me ha escapado al final en mi comentario la referencia a los sistemas de tipos graduales (como el de typed clojure o el que tomo de referencia de racket/scheme) u opcionales (como Dart) pero estaba pensando en los mismos.
    Pero tambien en otras herramientas de analisis y validacion estatico/ejecucion que no son tipos como la libreria schema para clojure (https://github.com/Prismatic/schema) de prismatic o los contratos (https://en.wikipedia.org/wiki/Design_by_contract) que suelen ser opcionales. Tambien entrarian aqui los tests, por supuesto.
    El problema es que desde cierto punto de vista un programa nunca esta acabado, sobre todo si la realidad que modela/representa/gestiona no es fija.
    ¿Cuantas veces hemos tenido que quitar una restriccion de un programa porque la realidad ha cambiado o porque los usuarios han decidido que molesta mas que ayuda?¿Cuantas otras ha habido que modificarlas y su refactorizacion implica un alto coste, al que se añade el modificar los tipos, contratos, tests o lo que usemos para representarlas?
    Tambien añadirlas cuando los usuarios se cansan de repetir los mismos errores una y otra vez claro (en mi caso esto pasa menos), o cuando los errores tienen consecuencias inaceptables y/o irrecuperables.
    El problema fundamental es saber que fijar y cuando hacerlo y elegir el metodo de fijacion que mas se ajuste al problema en cuestion. El poder hacerlo parcialmente o gradualmente parece que puede ayudar.
    Tener un programa (compilador) que te permita declarar las restricciones y no codificarlas (como los tests y contratos) esta claro que tambien.

    (En haskell tambien se puede suspender la validacion de tipos parcialmente: https://wiki.haskell.org/GHC/Typed_holes pero no tiene que ver con un lenguaje dinamico que puede generar valores, que pueden ser erroneos o no)

  4. Llevo siguiendo Schema desde hace tiempo y me parece una idea atractiva, sobre todo si llegan a integrarlo con core.typed para dar posibilidad de validación (opcional) en tiempo de compilación sin tener que repetir las anotaciones de tipos en los dos formatos.

    Me gusta porque mezcla el rol del tipado «pseudoestático» con el tipado «débil», permitiendo hacer coerciones de tipos más o menos controladas entre distintas estructuras de datos.

  5. Yo creo que el mayor problema del tipado estructural y/o dinámico, y el duck typing (especialmente cuando se permiten modificar tipos en tiempo de ejecución o en un scope diferente al de la declaración del tipo), es que el código acaba siendo bastante inmanejable en proyectos de cierta envergadura, especialmente cuando el código es compartido (y más si el código es compartido entre gente que no tiene comunicación directa).

    Al final uno no tiene claro donde está definido qué, por qué esto funciona y por qué no, y empiezan a aparecer las rutinas y variables «mágicas».

    No es nada que no se pueda solucionar con disciplina a la hora de programar, pero todos los que hemos hecho proyectos grandes sabemos que la disciplina se va por el desagüe cuando aprietan las deadlines o los recortes de budget.

    Yo tengo cierto proyecto de PHP (de un framework/sistema que yo no he hecho) cuyas clases a heredar para implementarle funcionalidad al framework dependen de la ruta física donde estén dichas clases. Y uno puede decir que esto es algo que es simplemente un error de diseño, que el framework no debería ser así… pero yo os digo ahora que es el sistema de eCommerce más extendido (y caro) entre empresas de eCommerce de cierta magnitud del mundo, y el considerado el mejor por muchos (yo incluido), y la perspectiva de que sea un proyectillo donde han tomado decisiones erróneas cambia.

    Vamos, que el que un lenguaje permita ciertas atrocidades como esas, uno puede decir que da «flexibilidad», pero si el resultado final es un monstruo inmanejable (hasta el punto que el sistema este que comento -aun siendo usado y pagado por las mayores empresas de eCommerce del mundo- se está reescribiendo de 0 para su nueva versión), pues da que pensar.

    Y si, monstruos inmanejables se pueden hacer en todos los sistemas, pero en lenguajes con un tipado estático (o mixto para casos en los que se necesiten) más básico (como C#, que es definitivamente mi lenguaje preferido de esta última década), es especialmente complicado hacerlos, al menos en cuanto a tipos se refiere. Desde luego se podrían hacer en C# un sistema de clases dependiente de la ruta física donde se encuentre el archivo que contiene la implementación… pero es desde luego, complejo de hacer, y nadie en su sano juicio lo haría.

    Desde luego, opinión personal e intransferible, «your mileage may vary».

  6. Estoy lejísimos de ser experto (en cualquier cosa diría XD XD) pero gracias, ser cabezón también da sus frutos XD XD

    Un post difícil y atrevido, en todos los sentidos, me gustaría ver más posts sobre los sistemas de tipos (constructivos). Lo que más me ha gustado ha sido

    «lo que me ha llevado a preguntarme hasta dónde podría ayudarme un sistema de tipos más potente»

    pues no es algo que muchos hagan.

    En la práctica y en el contexto actual, yo respondería a la cuestión diciendo que muchísimo, aunque con un sistema de tipos básico (estático y estricto a poder ser) se resolverían la mayoría de problemas, realmente no hace falta mucho más.

    Ya en otro contexto, cuando académicamente avance más o haya más cultura de basarse en tipos, creo realmente que nos llevarán al siguiente estadio de la composición y reusabilidad del software, pero anda verde la cosa y la industria no está para hacer experimentos XD XD

    Es perfectamente entendible que la gente prefiera (x ej) Python y usarlo directa y fácilmente que meterse en bregados con Haskell, las ventajas se obtienen después de mucho esfuerzo por las razones que he indicado antes, pero dudo que el futuro sea seguir usando «patos» XD XD

  7. @josejuan he, experto no se pero no conozco a muchos que hayan levantado una web app usando yesod :-P
    No estoy del todo de acuerdo con lo de:
    con un sistema de tipos básico (estático y estricto a poder ser) se resolverían la mayoría de problemas, realmente no hace falta mucho más.
    Aunque es cierto que se pueden resolver mas cosas exprimiendolos convenientemente tienen una serie de dificultades (rigideces, mucho codigo ceremonial, falta de interactividad y de expresividad) que hacen que la opcion de los patos sea mas atractiva. De hecho la popularidad de los lenguajes dinamicos se debe en parte a los anquilosados sistemas de tipos de java and co.
    No me parece que usar un sistema de tipos potente suponga mucho mas esfuerzo que desentrañar (algunos de) los misterios de cualquier framework enterprise que ronda por ahi.

  8. «No me parece que usar un sistema de tipos potente…»

    problemas muy diferentes, aunque básicamente estoy de acuerdo ¡sólo tenemos que convencer al resto! ;P

    «que hacen que la opcion de los patos sea mas atractiva»

    bueno, aquí sería donde empiezan las dificultades a la hora de fijar criterios comunes XD XD pero sobre todo, que el tema es amplio, complejo y con muchísimos matices y *contextos*, inabordables sin un café XD XD

    supongo que en la práctica todo depende de lo que cada cual prefiera priorizar y/o dar más importancia, costumbres y demás, hay a quien los tipos no le ayudan para nada y otros somos felices con ellos, no se, difícil emitir juicios y opiniones en cuatro líneas sin entrar en malinterpretaciones (ej. si uno dice que es pereza no crear un phantomtype el otro dice que no puedes estar regodeándote en el código y el otro replica que entonces que haces haciendo tests, etc…).

    de lo que *creo* estar seguro, es que *ya* podríamos escribir código de mucha mayor calidad, reusabilidad, … snif!

  9. Gracias a todos por dedicar tiempo a explicaros.

    Dejando de lado lo de distintos contextos, gustos personales, situaciones y demás preavisos, mi teoría (ni científica ni comprobada, por supuesto) es que debe llegar un punto en que no merece la pena complicarse más con los tipos.

    Hay características que se han mencionado, como por ejemplo la inferencia, el tipado gradual u opcional, que son aparentemente gratis (o al menos yo no les veo contrapartida), pero hay otras que obviamente tienen un coste (mayor verbosidad, rigidez, curva de aprendizaje).

    No tengo ni idea de dónde está el límite (y aquí entraría lo del depende, contexto, situación, etc.), pero supongo que a partir de cierta complejidad el esfuerzo de especificar y mantener correctamente los tipos (incluso con inferencia) no compensa las ventajas que se obtienen.

    Me recuerda un poco (salvando las distancias) a lo que ocurre con los tests. Aparentemente es mejor tenerlos que no, pero hay casos en los que aportan tan poco que no merece la pena invertir tiempo en escribirlos, y además se convierten en un lastre porque se gasta más tiempo manteniéndolos que lo que ahorran en detección de errores.

Comentarios cerrados.