# Type systems

clock

Jan 11, 2023

Los sistemas de tipos en los lenguajes de programación

Darío Scattolini

Darío Scattolini

12 min read

.

Software Engineering

Article

A la hora de programar ya estamos acostumbrados a lidiar con comportamientos inesperados y misteriosos TypeError en la consola. Nos hemos convertido expertos en debugging, ya sabemos cómo reaccionar a estas situaciones y encontramos la salida cada vez más rápido. Sin embargo, un poco de teoría nunca está de más para comprender cómo surgen y cómo se evitan algunos de los errores más frecuentes que encontramos. Es por eso que en este post me adentraré en el mundo de los sistemas de tipos. Vamos a repasar qué son los tipos, por qué los lenguajes de programación necesitan adoptar un sistema de tipos, y qué variantes encontramos entre los sistemas de tipos de los distintos lenguajes.

¿Por qué hay tipos?

Todos los lenguajes de programación realizan operaciones que transforman datos, sea mutándolos o creando datos nuevos como resultado. Esto incluye desde operaciones con datos simples, como sumar enteros o concatenar strings, hasta funciones que transforman datos complejos como objetos o colecciones (filter, sort, etc.).

Ejemplos de manipulación de datos en Kotlin
Fig.1 - Ejemplos de manipulación de datos en Kotlin

Sin embargo, hay operaciones o funciones que no tienen sentido para algunos datos. x + y tiene sentido si x e y son números, pero no si son clientes.

Ejemplos de opreraciones no permitidas en Kotlin
Fig.2 - Ejemplos de opreraciones no permitidas en Kotlin

Esto significa que un lenguaje no puede definir sus operaciones si no tiene un sistema de tipos que permita clasificar los datos y especificar con qué tipo de datos puede realizar cada operación. No podemos definir la multiplicación u otras operaciones aritméticas si no definimos tipos como Int o Double para los que la multiplicación es válida, así como no podemos definir métodos como filter si no tenemos un tipo de objetos como Collection a los que puede aplicarse.

Los lenguajes de programación incluyen un conjunto de tipos de datos «de fábrica» (por ejemplo: Int, String, Array, etc.), pero también suelen permitir a los programadores construir sus propios tipos (clases, interfaces, etc., como el caso de Customer en el anterior ejemplo).

Type-checking

Al encontrarse con una operación en el código, la implementación de un lenguaje de programación debe realizar un proceso que se conoce como type-checking: determinar si la operación tiene sentido dada la definición de la operación y los tipos de los datos involucrados.

Por ejemplo, si nuestro código contiene la declaración val sum = x + y, en algún momento durante su compilación o su ejecución ocurre una evaluación de este estilo:

  • ¿De qué tipos son x e y?
  • ¿Hay alguna definición del operador + que acepte estos tipos? P. ej.:
    • Int + Int -> la operación devuelve la suma de x e y.
    • String + String -> la operación concatena los valores de x e y.
    • String + Int -> la operación arroja un TypeError.

Este procedimiento de type-checking es esencial en cualquier lenguaje de programación, ya que de él depende si la operación con determinados valores se puede realizar, o cuál de las operaciones asociadas a un mismo operador (o a una misma función sobrecargada) se puede realizar.

Tipado estático vs. dinámico

Aunque todos los lenguajes realizan alguna clase de chequeo de tipos, podemos diferenciarlos en función del momento en que realizan el type-checking.

Lenguajes de tipado estático

En los lenguajes de tipado estático el chequeo de tipos se realiza antes de la ejecución, normalmente durante la compilación del programa (compile-time). Ejemplos de lenguaje de este tipo son Java, Kotlin, C++, C#, Go y TypeScript.

Esto normalmente requiere explicitar tipos en cada declaración de variable, por ejemplo en Java. Otros lenguajes más laxos en este aspecto, como Kotlin o TypeScript, permiten la inferencia de tipos: el tipo de una variable puede no ser explicitado ya que es inferido del valor con el que se inicializa la variable.

En cualquier caso, el requisito principal del tipado estático es que el tipo inicial de una variable no puede cambiarse en una nueva asignación de valor. Que los identificadores de un lenguaje preserven sus tipos es condición esencial para la realización de un type-checking durante la compilación. Si el programa no se está ejecutando no disponemos de los valores actuales de los operandos para verificar de qué tipo son, sólo podemos saber el tipo si rastreamos cómo se ha llegado al operando hasta el momento de su declaración/asignación, por lo que esa asignación de tipo inicial debe mantenerse. Gracias a esto, en el siguiente ejemplo el compilador podrá saber que la multplicación realizada al final será entre dos enteros, y por lo tanto que el tipo de result también sera un entero.

Ejemplos de tipado estático en Kotlin
Fig.3 - Ejemplos de tipado estático en Kotlin

Los requerimientos del tipado estático añaden complicación a la hora de escribir código, ya que el compilador exigirá que se le provea la información suficiente para saber antes de ejecutar el programa de qué tipo serán los valores involucrados en cualquier operación. Esto es particularmente el caso en lenguajes que no realizan inferencia de tipos, en los cuales la gran cantidad de declaraciones explícitas dificulta la legibilidad del código.

Sin embargo, el tipado estático permite detectar errores con anticipación, ya que el propio compilador identifica la incompatibilidad de tipos antes de que se ejecute el programa. Esto posibilita ahorrar los costos que pueden generar los bugs en producción.

Código Kotlin que no compila
Fig.4 - Código Kotlin que no compila

Además significa que el error puede detectarse en el mismo momento en que se crea, ya que en general los editores de código también realizan el chequeo estático de tipos mientras se escribe el código. Esto ahorra tiempo de debugging, ya que la inmediatez del feedback indicará al programador que la causa del error es lo que sea que acaba de hacer.

El tipado estático además suele redundar en una mayor productividad, sobre todo en proyectos grandes. Hemos dicho que los editores de código explotan el tipado estático, pero esto no es sólo para identificar código que no compila, sino también para sugerir qué métodos o propiedades pueden invocarse desde un determinado identificador. Por ejemplo, si sabemos que names es una lista, al escribir names el editor de código sugerirá una lista de métodos o propiedades pertenecientes a listas, lo que ayudará a identificar rápidamente la función que deseamos invocar. Esto incluso es útil para descubrir features de los lenguajes o librerías que estamos usando.

Lenguajes de tipado dinámico

En los lenguajes de tipado dinámico el chequeo de tipos es diferido hasta el último momento: la ejecución del programa (run-time). En el momento de ejecutar la operación se decide si esta es realizable, verificando los valores que tienen actualmente los operandos involucrados. Luego la operación se realiza o se arroja un run-time error. Ejemplos de lenguajes con tipado dinámico son Python, JavaScript, PHP, Ruby y Lisp.

Al no estar garantizada la consistencia de tipos de antemano, este enfoque es más propenso a errores que pueden ser difíciles de identificar: hay que rastrear qué punto de la ejecución del programa ha determinado que alguno de los elementos de la operación no sea del tipo adecuado. En el siguiente ejemplo de JavaScript no hay ningún requerimiento de que la función getNumber devuelva un número. Si devuelve un string la operación que da como resultado double no se podrá realizar, y será necesario debugar las funciones getNumber, getUserInput o fetchNumberFromApi para determinar de dónde ha surgido ese string. La causa podría estar en algún lugar lejano e inesperado de los call stacks generados por esas funciones, por lo que el debugging podría llevar mucho tiempo.

Código que podría generar un TypeError en JS
Fig.6 - Código que podría generar un TypeError en JS

Este tipo de run-time errors puede detectarse en tests, pero es de esperar que si el código no tiene la suficiente cobertura o no se han identificado todos los casos límite también surjan en contexto de producción, por lo que podrían generar un costo importante dependiendo de sus implicaciones.

Sin embargo, el tipado dinámico habilita ciertas operaciones durante el run-time que de otro modo no serían posibles. Por ejemplo, se puede cambiar el tipo de una variable, o añadir una propiedad a un objeto que no está incluida en la definición de su clase. Al ser lenguajes más flexibles y sencillos de escribir, puede ser más práctico su uso para scripts acotados que no se ramifican en una gran base de código.

Tipado fuerte vs. tipado débil

Los lenguajes de programación no sólo se clasifican según cuándo se realiza, sino también en función de qué tan estricto es el type-checking.

Lenguajes de tipado fuerte

Si el chequeo de tipos detecta que los tipos de los operandos no se corresponden con los de la operación invocada, la operación simplemente no se realiza y se arroja un TypeError. Al arrojarse una excepción, si ésta no es capturada y manejada de forma apropiada puede detenerse por completo la ejecución del programa. Lenguajes que exhiben este comportamiento son Python, Ruby, Java, Kotlin o C#.

Ejemplo de TypeError en Python
Fig.7 - Ejemplo de TypeError en Python

Lenguaje de tipado débil

Una alternativa a este comportamiento la ofrecen los lenguajes que en casos muy puntuales intentan salvar las inconsistencias entre tipos en una determinada operación, transformando los valores de un tipo a otro siguiendo determinadas reglas de coerción. La coerción es una conversión implícita de tipos: no es una conversión instruida por el programador en el código, sino que la realiza el intérprete del programa a los efectos de poder realizar la operación.

Por ejemplo, JavaScript permite la siguiente operación, que resulta de convertir implícitamente número 3 al string "3" para que una concatenación sea posible:

Ejemplo de coerción de tipos en JS
Fig.8 - Ejemplo de coerción de tipos en JS

Esto puede ser útil en algunos lenguajes de tipado dinámico, ya que ayuda a evitar algunos runtime errors. Sin embargo, si no se conoce las reglas de coerción del lenguaje que se está utilizando es muy fácil ser víctima de comportamientos imprevistos muy difíciles de resolver. En muchas ocasiones es mejor que surja un error durante la ejecución, como síntoma de que algo anda mal, antes que arrastrar un desperfecto difícil de debugar por una conversión inesperada. Las reglas de coerción pueden tan arbitrarias e inverosímiles que en lenguajes como JavaScript ha dado origen a toda una categoría de chistes.

Ejemplos de lenguaje con algún grado de tipado débil son JavaScript, PHP, C y C++. Es importante tener en cuenta que la división entre tipado fuerte y débil es más bien gradual, ya que no existe un lenguaje con tipado absolutamente débil. Todo lenguaje tiene operaciones inadmisibles en las que se verá obligado a lanzar un TypeError. Lo que los diferenciará en este aspecto es sólo la cantidad de reglas de coerción que adopten.

Conclusiones

Para finalizar este post me gustaría puntualizar algunas ideas:

  • Todos los lenguajes de programación suponen un sistema de tipos. Es común escuchar que lenguajes como JavaScript «no tienen tipos» por ser de tipado dinámico y débil, pero eso es incorrecto. Sólo ocurre que posponen el chequeo de tipos y no son demasiado estrictos al realizarlo.
  • A menudo se confunde tipado dinámico con tipado débil y tipado estático con tipado fuerte. Sin embargo, como hemos visto, son dos ejes diferentes a lo largo de los cuales se pueden clasificar los lenguajes de programación:
  • Un lenguaje de programación no es mejor que otro por su sistema de tipos. Los lenguajes son herramientas que se adaptan de manera distinta a situaciones distintas. En algunos contextos puede ser necesario el tipado estático para garantizar la consistencia de la base de código, pero en otros contextos esto puede ser demasiado rígido. El sistema de tipos es una variable importante a la hora de elegir qué lenguaje utilizar, pero debe ser tenido en cuenta no en abstracto o por preferencias personales, sino en función de la tarea que se va a realizar, y en conjunto con otras variables (p. ej., si su ecosistema cuenta con librerías que pueden simplificar el desarrollo del proyecto).
  • No hay nada mejor para comprender los sistemas de tipos que experimentarlos a todos. Volviendo al punto anterior, los lenguajes son sólo herramientas, y casarnos con sólo una de ellas no nos volverá más expertos. El contraste de aprender a programar en un estilo diferente, aunque no sea nuestro preferido, no sólo nos enseña sobre un nuevo estilo sino que ilumina aspectos que no habíamos notado del que ya estábamos utilizando.

Table Of Contents