# Type systems
11 de ene de 2023
Darío Scattolini
12 min de lectura
.
Software Engineering
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.
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.).
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.
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).
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:
x
e y
?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.
Aunque todos los lenguajes realizan alguna clase de chequeo de tipos, podemos diferenciarlos en función del momento en que realizan el type-checking.
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.
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.
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.
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.
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.
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.
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#.
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:
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.
Para finalizar este post me gustaría puntualizar algunas ideas: