No abuséis de los tipos de datos primitivos
Por Jacobo Tarrío
17 de enero de 2021

Pedidle a un programador moderno que os diseñe un programa, y pronto os dará un diagrama con clases llamadas Cliente y Empleado, y Usuario y UsuarioDAO, y ConnectionProvider y ConnectionProviderFactory y AbstractConnectionProviderFactoryImpl.

Bueno, tal vez sólo los programadores Java se metan en tales fregados, pero está claro que, hoy en día, cualquier programador conoce y utiliza los conceptos básicos de orientación a objetos. Incluso en lenguajes no orientados a objetos, los programadores crean y usan structs, o records, o Types o cualesquiera mecanismos del lenguaje para crear nuevos tipos de datos.

Y, aún así, por muy sabios que nos consideremos los programadores en la actualidad, en nuestros programas todavía sobreviven vestigios de los viejos tiempos en los que Fortran y COBOL y BASIC sólo nos proporcionaban tipos de datos primitivos. Cada vez que queremos almacenar una clave primaria de un registro de la base de datos, la ponemos en una variable de tipo long o string. Si queremos pasarle un timestamp a una función, usamos un argumento de tipo int o long. ¿Una función que devuelve un UUID? Tipo string. ¿Una cadena de texto recibida del usuario? ¿Una cadena de texto lista para insertar en un documento HTML? Ambas, tipo string.

Utilizamos los mismos tipos de datos, una y otra vez, para cosas tan diferentes como claves primarias de distintas tablas y timestamps en segundos y milisegundos, o URIs y UUIDs. Ésta es una práctica extremadamente habitual que, también, es una causa de errores extremadamente habitual. ¿Quién no ha escrito nunca código que intenta buscar un registro en la tabla equivocada, o que compara segundos y microsegundos, o que inserta una cadena de texto sin escapar en un documento HTML?

En este artículo os describo tres estrategias para tratar con esos objetos, evitar confusiones como las que describo arriba, y evitar errores de programación.

Utilizad nuevos tipos para nuevas entidades

¿Conocéis el Internet Archive? Es una organización sin ánimo de lucro que quiere formar una “biblioteca de Internet” archivando todas las páginas web del mundo para conservarlas y para que podamos verlas en el futuro.

Podéis suponer que el código fuente del Internet Archive trabaja mucho con URIs. Cada página web tiene su propio URI y contiene otros URIs de todas las páginas web a las que enlaza. En el archivo, las páginas web están indexadas mediante sus URIs para que los usuarios puedan acceder a ellas. En definitiva, para el Internet Archive, los URIs son el pan nuestro de cada día.

Los programadores siempre estamos tentados de almacenar URIs en variables de tipo string. Vemos una cadena alfanumérica y nos decimos: “eso es fácil: es una cadena de texto, así que tipo string”. Sin embargo, los URIs no son sólo cadenas de texto. Los URIs tienen ciertas reglas que deben cumplir en todo momento: han de seguir un cierto formato, ciertos caracteres no son válidos, hay secuencias de escape, etc.

Cuando almacenamos un URI en una variable de tipo string, el sistema no sabe nada de esas reglas. La variable podría contener un URI mal formateado, o incluso algo que no es un URI en absoluto, y no habría ningún mensaje de error ni nada parecido hasta que intentásemos usarla y se rompiera algo. Por lo tanto, necesitamos escribir (y acordarnos de utilizar siempre) código que, cada vez que asignemos un nuevo valor a la variable, lo verifique y normalice. Si no lo hacemos, nos exponemos a una multitud de posibles errores de programación. Tal vez no seamos capaz de encontrar la página web que el usuario busca, tal vez tengamos un XSS en nuestra página, o algún otro problema entre medias.

Para solucionar el problema con más eficacia tenemos que reconocer que un URI no es una cadena de texto que podamos manipular usando sólo variables de tipo string, sino que tenemos que utilizar una clase específica que codifique todas las reglas y el formato de un URI. Esta clase URI ha de tener funciones para convertir una cadena de texto en un objeto de tipo URI, aplicando todas las reglas de validación y normalización, y también necesita tener funciones para extraer las distintas partes del URI y obtener una representación del URI en forma de cadena de texto.

La mayoría de los lenguajes modernos incluyen una clase URI en su biblioteca estándar, así que sólo tendremos que acordarnos de utilizarla en lugar del tipo string en cualquier lugar en el que tratemos con URIs.

El Internet Archive, en su página principal, tiene una caja de texto en la que un usuario puede escribir un URI y ver qué había en esa página en algún momento del pasado. El servidor web recibe la petición web que contiene ese URI en forma de cadena de caracteres; lo primero que hace el servidor es convertirlo en un objeto de tipo URI. A partir de ese momento, ese URI está validado y formateado, por lo que el servidor puede pasarlo al servicio de almacenamiento, que podrá recuperar el contenido de la página web.


A veces no podemos utilizar clases proporcionadas por nuestra biblioteca estándar y tenemos que escribirlas nosotros mismos. Por ejemplo, hace años trabajé en un proyecto que tenía una base de datos que almacenaba objetos indexados mediante un identificador que seguía el formato HHHHHHHH-V, donde H era un dígito hexadecimal y V era un número de versión de uno o más dígitos.

Los programadores originales del sistema no se habían puesto de acuerdo en cómo tratar estos identificadores. En algunas partes del programa, el identificador iba almacenado en una variable de tipo string. En otras partes, el identificador estaba dividido en dos variables: una de tipo string que contenía los dígitos hexadecimales y otra de tipo int que contenía el número de versión. Nuestro código muchas veces se parecía a esto:

public List<String> buscaIdsAsociados(String id)
        throws IllegalArgumentException, NotFoundException {
    if (!esValido(id)) {
        throw new IllegalArgumentException();
    }
    String hexa = extraeHexa(id);
    int version = extraeVersion(id);
    Registro registro = buscaRegistro(hexa, version);
    List<String> idsAsociados = new ArrayList<>();
    for (Registro asociado : registro.getRegistrosAsociados()) {
        idsAsociados.add(asociado.getHexa() + "-" + asociado.getVersion());
    }
    return idsAsociados;
}

public Registro buscaRegistro(String hexa, int version) {
    Query query = createQuery("SELECT * FROM Registros WHERE hexa=?, version=?",
                              hexa, version);
    return convertir(query.execute());
}

Como podéis ver, era un jaleo. Nos pasábamos el tiempo validando identificadores y dividiéndolos en dos partes y reensamblándolos y buscando errores causados por sitios donde nos habíamos olvidado de validar o donde habíamos pasado un identificador cuando necesitábamos la parte hexadecimal o viceversa.

La solución a este problema consistió en reconocer que un identificador es un nuevo tipo de entidad y no sólo una cadena de texto, y crear una clase dedicada a almacenar y manipular identificadores.

public class Identificador {
    private String hexa;
    private int version;

    private Identificador(String hexa, int version) {
        this.hexa = hexa;
        this.version = version;
    }

    public String getHexa() { return hexa; }
    public int getVersion() { return version; }
    public String toString() { return hexa + "-" + version; }

    public static Identificador crear(String id) throws IllegalArgumentException {
        // Validación y extracción de las partes del identificador
        ...
        String hexa = ...;
        int version = ...;
        return new Identificador(hexa, version);
    }
}

Y, con esto, pudimos simplificar nuestro código, evitando las constantes validaciones y tratando los identificadores de la misma manera en todo el programa, eliminando cientos de líneas de código y oportunidades para introducir errores.

public List<Identificador> buscaIdsAsociados(Identificador id)
        throws NotFoundException {
    Registro registro = buscaRegistro(id);
    List<Identificador> idsAsociados = new ArrayList<>();
    for (Registro asociado : registro.getRegistrosAsociados()) {
        idsAsociados.add(asociado.getIdentificador());
    }
    return idsAsociados;
}

public Registro buscaRegistro(Identificador id) {
    Query query = createQuery("SELECT * FROM Registros WHERE hexa=?, version=?",
                              identificador.getHexa(), identificador.getVersion());
    return convertir(query.execute());
}

Utilizad distintos tipos para distintas entidades

En el mundillo de las bases de datos hay dos escuelas: la escuela de los que utilizan INTs para las claves primarias y la escuela de los que utilizan UUIDs. Sea cual sea la escuela a la que pertenezca vuestro DBA, vuestro código va a acabar lleno de variables, todas del mismo tipo, que contienen claves primarias pertenecientes a distintas tablas.

long idArticulo = getLongParam("idArticulo");
long idComentario = getLongParam("idComentario");
Comentario comentario = cargaComentario(idArticulo, idComentario);
long idUsuario = comentario.idAutor();
Usuario usuario = cargaUsuario(idArticulo);
outputTemplate("comentario", usuario, comentario);

Este podría ser parte del código fuente de un CMS: una función que gestiona la respuesta a una operación HTTP GET para mostrar un comentario de un artículo. Esta función recibe un identificador de artículo y de comentario, carga el comentario y los datos del usuario que publicó el comentario, y los convierte a HTML para mostrarlos en el navegador.

La base de datos utiliza números enteros para las claves primarias, y el lenguaje las almacena en variables de tipo long. Tenemos una clave para el artículo, otra para el comentario y otra para el usuario. Y como todas estas claves van en variables del mismo tipo, es muy fácil confundirse y mezclarlas sin darse cuenta. De hecho, el código de arriba contiene un error. ¿Cuánto tiempo os lleva descubrirlo?

Aunque todas las claves primarias van en variables del mismo tipo, no son intercambiables. Si pasamos un identificador de usuario a una función que espera un identificador de artículo, esta función nos dará un resultado incorrecto. Lo malo es que no nos daremos cuenta de esta confusión hasta que probemos el programa y nos demos cuenta del error; o, peor todavía, hasta que un usuario lo vea y nos avise. O, lo peor de todo, nunca nos daremos cuenta y terminaremos corrompiendo datos.

Si estas claves no son intercambiables en la práctica, tampoco deberían ser intercambiables en el código. Podemos lograr esto muy fácilmente usando clases distintas para las claves primarias de cada tabla.

IdArticulo idArticulo = new IdArticulo(getLongParam("idArticulo"));
IdComentario idComentario = new IdComentario(getLongParam("idComentario"));
Comentario comentario = cargaComentario(idArticulo, idComentario);
IdUsuario idUsuario = comentario.idAutor();
Usuario usuario = cargaUsuario(idArticulo); // El compilador nos da un error aquí.
outputTemplate("comentario", usuario, comentario);

Crear estas clases es superfácil en la mayoría de los lenguajes modernos. Por ejemplo, en Java, podemos crear una clase base que incorpore toda la funcionalidad, y después añadir una línea de código para cada tipo.

public abstract class Id {
    private long id;
    public Id(long id) { this.id = id; }
    public long getId() { return id; }
    public String toString() { return getClass().getSimpleName() + "=" + id; }
}

public IdArticulo extends Id { public IdArticulo(long id) { super(id); } }

public IdComentario extends Id { public IdComentario(long id) { super(id); } }

public IdUsuario extends Id { public IdUsuario(long id) { super(id); } }

Esta técnica también os va a resultar útil en tareas de procesamiento de datos, en las que podríamos estar manejando datos pertenecientes a distintas etapas de procesamiento al mismo tiempo sin querer mezclarlos. Hoy en día, os la encontraréis más habitualmente a la hora de evitar XSS en aplicaciones web.

Muchas aplicaciones web necesitan recibir una cadena de texto del usuario, procesarla de alguna manera, y finalmente mostrarla en una página web. Si no tenéis cuidado y pegáis ese texto directamente en el HTML, os expondréis a un ataque XSS; como mínimo, tenéis que escapar esa cadena de texto antes de ponerla en el HTML. A veces, los programadores de las aplicaciones web se confunden y escapan una cadena dos veces, y después los usuarios ven cosas raras como “t&eacute;cnica” en lugar de “técnica”. O hacen mal el escape de cadenas para SQL y después los usuarios ven “O\'Connell” en lugar de “O'Connell”.

Para evitar estos problemas, muchos frameworks web modernos no nos permiten pegar strings directamente, sino que nos obligan a usar tipos especiales que contienen cadenas de texto ya escapadas. Podemos crear una instancia de uno de esos tipos a partir de una cadena sin escapar, y a partir de entonces ya no cabe confusión: dondequiera que utilicemos esa clase, es una cadena ya escapada y lista para insertar en código HTML.

Utilizad un solo tipo para una sola entidad

Es muy habitual utilizar ints y longs para representar intervalos de tiempo. La cuestión es: ¿en qué unidad? El sistema operativo Unix utiliza segundos pero los lenguajes Java y JavaScript utilizan milisegundos. Yo he trabajado con sistemas que utilizaban microsegundos y nanosegundos. Muchas veces es necesario utilizar distintas unidades en distintos lugares de un mismo programa, dependiendo de quién haya escrito el código o qué función reciba un determinado número.

El problema es que todos esos intervalos de tiempo se representan como un long, así sin más, sin marcar las unidades empleadas de ninguna manera, así que es muy fácil pasar a una función un número de milisegundos cuando espera segundos, o restar microsegundos de nanosegundos, u otras operaciones sin sentido que acabamos realizando sin querer.

Podríamos caer en la tentación de intentar solucionar este problema creando un nuevo tipo para cada unidad. Una clase Segundos almacenaría un intervalo medido en segundos, una clase Milisegundos almacenaría otro intervalo medido en milisegundos, y así sucesivamente. De esta manera ya no podríamos mezclar distintas unidades sin que el compilador se quejara.

El problema es que, muchas veces, necesitamos convertir entre unas unidades y otras; a veces tenemos una función que devuelve un intervalo medido en segundos que tenemos que pasar a otra función que espera milisegundos, así que necesitamos hacer una conversión. Otras veces, tenemos que combinar dos intervalos que pueden estar medidos en distintas unidades. Esto podríamos solucionando añadiendo funciones de conversión y de suma y de resta para cada par de unidades, pero con cuatro unidades, esto acabarían siendo cuarenta y cuatro funciones en total. Eso es un montón de funciones.

public class Segundos {
    public Milisegundos milisegundos() { return ... }
    public Microsegundos microsegundos() { return ... }
    public Nanosegundos nanosegundos() { return ... }
    public Segundos suma(Segundos otro) { return ... }
    public Segundos suma(Milisegundos otro) { return ... }
    public Segundos suma(Microsegundos otro) { return ... }
    public Segundos suma(Nanosegundos otro) { return ... }
    public Segundos resta(Segundos otro) { return ... }
    public Segundos resta(Milisegundos otro) { return ... }
    public Segundos resta(Microsegundos otro) { return ... }
    public Segundos resta(Nanosegundos otro) { return ... }
}
// Y otras tres veces igual para Milisegundos, Microsegundos y Nanosegundos

Francamente, esta opción no es sostenible. A poco que necesitemos añadir una o dos unidades u operaciones, tendremos que escribir una cantidad de código colosal y, para más inri, la mayor parte consistirá en operaciones de conversión.

La verdadera solución consiste en darse cuenta de que la entidad para la que estamos creando tipos de datos no es el número de segundos, milisegundos o microsegundos. La entidad es el intervalo de tiempo, y todas esas unidades no son más que maneras de medirlo. No necesitamos crear un tipo para cada unidad de tiempo; necesitamos crear un tipo para los intervalos de tiempo, que tenga las operaciones necesarias para poder expresar esos intervalos en las unidades adecuadas.

Por ejemplo, podríamos crear un tipo Intervalo que contenga una variable que expresa la longitud del intervalo en una unidad cómoda, y que también contenga funciones para expresar ese intervalo en segundos, milisegundos, microsegundos y nanosegundos, junto con funciones para hacer la conversión inversa.

public class Intervalo {
    private double valor;
    private Intervalo(double valor) { this.valor = valor; }
    // Constructores
    public static Intervalo deSegundos(long segundos) { return new Intervalo(segundos); }
    public static Intervalo deMilisegundos(long milisegundos) { return new Intervalo(milisegundos / 1e3); }
    public static Intervalo deMicrosegundos(long microsegundos) { return new Intervalo(microsegundos / 1e6); }
    public static Intervalo deNanosegundos(long nanosegundos) { return new Intervalo(nanosegundos / 1e9); }
    // Conversiones
    public long segundos() { return (long) valor; }
    public long milisegundos() { return (long) (valor * 1e3); }
    public long microsegundos() { return (long) (valor * 1e6); }
    public long nanosegundos() { return (long) (valor * 1e9); }
    // Combinaciones
    public Intervalo suma(Intervalo otro) { return new Intervalo(valor + otro.valor); }
    public Intervalo resta(Intervalo otro) { return new Intervalo(valor - otro.valor); }
}

Y esto es todo lo que necesitamos para poder representar intervalos de tiempo medidos en varias unidades, y cada vez que añadimos una nueva unidad, sólo necesitamos añadir dos funciones.

Ahora podemos utilizar esta clase para sustituir en nuestro código todos esos longs expresados en unidades indeterminadas, y así evitar todas las confusiones y oportunidades para errores que mencioné más arriba.

// Viejuno.
// ¿En qué unidad está el _timeout_? ¿Segundos? ¿Milisegundos? ¿Semanas?
Registro buscaRegistro(Identificador id, long timeout) { ... }

// La última moda.
Registro buscaRegistro(Identificador id, Intervalo timeout) { ... }

Muchos lenguajes de programación modernos incluyen una clase parecida a Intervalo en su biblioteca estándar. Por ejemplo, Java tiene el paquete java.time, que proporciona la clase Duration (junto con otra clase, llamada Instant, que representa un particular punto en el tiempo). El lenguaje C++, por su parte, proporciona el namespace std::chrono, con sus clases duration y time_point. En otros lenguajes hay bibliotecas de terceros que la proporcionan. Utilizadlas siempre que podáis; nunca uséis longs para representar el tiempo.

Conclusiones

A veces, utilizar un tipo primitivo del sistema es lo más sencillo pero puede acabar dándonos muchos quebraderos de cabeza. Tenemos que pensar en si el número que almacenamos en ese long es realmente un número o si lo que hay en ese string es una cadena de texto sin más, y crear y utilizar nuevos tipos de datos cuando éstos tengan algún tipo de complicación o restricción o invariante.

Los humanos sabemos que no podemos sumar dos cerezas a tres naranjas, pero los ordenadores, si sólo ven dos longs o dos doubles, los sumarán y dividirán sin pensárselo dos veces. Nosotros, los humanos que sabemos que las entidades distintas pertenecen a tipos distintos, tenemos que comunicarle esta distinción al ordenador, en forma de nuevos tipos de datos.

Y finalmente, nosotros sabemos que 60 segundos es lo mismo que un minuto, o que 5000 metros equivalen a 5 kilómetros; sin embargo, el ordenador sólo ve una variable que dice “60” y otra que dice “1”, o un valor “5000” y otro “5”. Depende de nosotros decirle al ordenador que ambas cosas son la misma.

La próxima vez que penséis en que estaría bien si pudiéseis “anotar” o “marcar” un número o una cadena de texto para tratarla especialmente, probad a crear y utilizar un nuevo tipo de datos. Al hacerlo, estoy seguro de que vuestros programas serán mucho más fiables y fáciles de leer y modificar.

There is an English translation of this story: “Do not overuse primitive data types”.
Otros artículos sobre “programación”.
Índice.
Salvo indicación en contrario, esta página y su contenido son Copyright © Jacobo Tarrío Barreiro. Todos los Derechos Reservados. Información sobre tratamiento de datos y condiciones de uso.