C++ y sus rarezas de diseño: por qué la compatibilidad hacia atrás lo explica casi todo

Hace unas semanas corrió por YouTube un vídeo de más de cincuenta minutos con un título que no deja lugar a dudas: «el peor lenguaje de programación de la historia». No hacía falta ver la miniatura para saber de qué lenguaje hablaba. Es C++, otra vez.

El vídeo genera la reacción de siempre: los que llevan años con C++ asienten a la mitad de las cosas, y los que vienen de otro lenguaje alucinan con el resto. Casi nadie para a pensar por qué un lenguaje que lleva desde 1985 en manos de algunas de las mentes más capaces de la industria sigue arrastrando decisiones que parecen sacadas de una broma. No es que a nadie se le haya ocurrido arreglarlas, es que arreglarlas costaría más de lo que aportan.

const empezó llamándose readonly

Coge cualquier fragmento de C++ moderno y vas a encontrar const por todas partes. Lo curioso es que Bjarne Stroustrup no lo llamó así al principio. En sus notas sobre la historia del lenguaje cuenta que sus primeros ejemplos usaban la palabra readonly, y que en algún momento decidió que const quedaba mejor en frases como const int c = 10; que la alternativa con readonly delante. El cambio se hizo con una sustitución global en el código fuente de C with Classes, el antecesor directo de C++, allá por 1981.

Lo que parece un detalle de nombres esconde algo más interesante: const nació con dos trabajos distintos a la vez, marcar constantes simbólicas sin recurrir a macros del preprocesador y declarar que un objeto en memoria no se puede tocar. C adoptó la palabra más tarde, en el estándar de 1989, ya con esos dos significados pegados. Por eso hoy const int* p y int* const p dicen cosas completamente distintas según a qué lado del asterisco pongas la palabra:

int valor = 10;

const int* p1 = &valor;   // el VALOR al que apunta no se puede tocar
int* const p2 = &valor;   // el PUNTERO no se puede redirigir

*p1 = 20;      // error de compilación
p1 = nullptr;  // esto sí compila, el puntero puede cambiar

*p2 = 20;      // esto compila, el valor sí puede cambiar
p2 = nullptr;  // error de compilación, el puntero es fijo

Nadie se atreve a tocar esa gramática porque rehacerla rompería cuarenta años de C y C++ a la vez.

int no mide lo que tú crees

Uno de los primeros golpes que se lleva cualquiera que viene de Java o Python es descubrir que int no tiene un tamaño fijo en C++. El estándar solo garantiza un mínimo de 16 bits y un orden, short <= int <= long <= long long. El motivo hay que buscarlo en el PDP-11, la máquina para la que Dennis Ritchie diseñó C a principios de los setenta. Ahí un int de 16 bits encajaba de forma natural con el tamaño de palabra del procesador, y la idea desde el principio fue que ese tamaño creciera junto con el hardware sin obligar a reescribir el código.

Con los años, int pasó a 32 bits en la mayoría de plataformas y ahí se quedó, incluso en máquinas de 64 bits, porque para entonces medio mundo tenía sizeof(int) == 4 hardcodeado en algún sitio. El caso más sangrante de esta lógica es long:

#include <cstdint>

sizeof(int);        // 4 en casi cualquier plataforma moderna
sizeof(long);        // 8 en Linux y macOS (modelo LP64)
                      // 4 en Windows (modelo LLP64)
sizeof(long long);   // 8 en prácticamente todas partes

int64_t contador = 0;   // 64 bits garantizados, da igual el sistema operativo

En Linux y en macOS, long mide 64 bits en una máquina de 64 bits, siguiendo el modelo LP64. En Windows sigue midiendo 32 bits, con LLP64, porque Microsoft prefirió mantener la compatibilidad binaria con todo el código de 32 bits que ya trataba long como si fuera de cuatro bytes, antes que alinear el tipo con el procesador. Mismo lenguaje, mismo estándar, dos respuestas distintas, y ambas correctas según las reglas. Si necesitas un entero de 64 bits de verdad en cualquier plataforma, la única apuesta segura sigue siendo int64_t.

std::move no mueve absolutamente nada

Esta es la que más rabia da explicar en una code review. std::move no mueve nada, solo convierte su argumento en una referencia rvalue para que el compilador pueda elegir un constructor de movimiento en vez de uno de copia. Howard Hinnant, uno de los principales diseñadores de la semántica de movimiento que llegó en C++11, lo dejó claro desde el principio: el nombre describe la intención del programador, no una operación real sobre la memoria.

std::vector<int> origen = {1, 2, 3, 4, 5};
std::vector<int> destino = std::move(origen);

// destino se queda con los datos de origen sin copiarlos
// pero origen sigue siendo un objeto válido, solo que vacío
std::cout << origen.size();   // 0, casi siempre (no está garantizado,
                               // pero así es como se comportan libstdc++ y libc++)

Para que eso funcionara, el comité tuvo que ampliar las dos categorías de valor que traía C, lvalue y rvalue, con una tercera pieza intermedia, la xvalue: un objeto que todavía tiene identidad pero cuyo contenido ya se puede reutilizar sin miedo. std::move es justo el cristal que convierte un lvalue normal en ese tipo de valor «a punto de expirar». El resultado es un nombre que cualquier recién llegado lee como un verbo de acción y que en realidad es un cast disfrazado con mejores modales.

vector, map y el resto de nombres heredados de un matemático

std::vector no tiene nada que ver con un vector geométrico, es un array dinámico de toda la vida. La razón hay que buscarla en Alexander Stepanov, la persona detrás de la Standard Template Library. Stepanov llevaba desde finales de los setenta dándole vueltas a la programación genérica, primero en Scheme y luego en Ada junto a David Musser, y ahí un «vector» ya designaba una secuencia contigua unidimensional. Cuando en 1994 presentó su propuesta al comité de estandarización y esta se aprobó casi sin cambios para entrar en C++98, el nombre viajó con ella.

Con std::map pasa algo parecido, aunque menos por herencia académica y más por cómo evolucionó el propio lenguaje. El nombre describe bien lo que hace, un mapeo de claves a valores, pero mucha gente que llega de Java o Python espera que un «map» se comporte como una tabla hash. En C++, map es un árbol balanceado con acceso logarítmico y orden por clave, y su operator[] esconde una trampa clásica:

std::map<std::string, int> contador;

if (contador["clave"] == 0) {
    // esto acaba de INSERTAR "clave" con valor 0 en el mapa,
    // aunque solo la estabas consultando
}

// la forma correcta de comprobar sin insertar nada:
if (contador.find("clave") == contador.end()) {
    // "clave" no existe todavía
}

Cuando por fin llegó una tabla hash real con C++11, el nombre obvio, hash_map, ya estaba ocupado por implementaciones incompatibles de SGI, Microsoft y otros proveedores desde años atrás. El comité no quiso romper esas implementaciones ya extendidas y acabó adoptando el único nombre libre que quedaba, unordered_map. Otra cicatriz de compatibilidad, no un error de diseño.

Concepts tardó treinta años en llegar

Si alguna vez te ha salido un error de plantillas que ocupa media pantalla y señala una línea perdida dentro de <algorithm>, ya sabes de qué va esto. Durante décadas, C++ no tenía forma de decirle al compilador «esta plantilla necesita un tipo que se pueda comparar» antes de intentar compilarla con tu tipo concreto. El compilador simplemente expandía la plantilla entera y esperaba a que algo fallara en las profundidades de la biblioteca:

// antes de C++20: si T no admite <, el error aparece
// varias capas de plantillas más abajo, no aquí
template <typename T>
T menor(T a, T b) { return (a < b) ? a : b; }

// con concepts, la condición queda declarada donde se usa,
// y el error señala esta línea directamente
template <typename T>
requires std::totally_ordered<T>
T menor(T a, T b) { return (a < b) ? a : b; }

La idea de arreglar esto, los llamados concepts, viene también de los trabajos de Stepanov de finales de los ochenta, y llegó a proponerse formalmente para C++11. El comité la retiró de ese estándar porque el diseño original resultó demasiado complejo, con problemas de ambigüedad en la resolución y axiomas que no se podían verificar en tiempo de compilación. Hizo falta una versión simplificada, apodada «Concepts Lite», y varios intentos fallidos de meterla en C++17, para que la propuesta cuajara por fin en C++20. Treinta años entre la idea y su llegada al estándar dan una idea de lo que cuesta cambiar el corazón de un sistema de tipos sin romper el código que ya corre sobre él.

La factura de no romper nada

Todo lo anterior comparte un mismo patrón. C++ podría llamar a las cosas de otra manera, fijar el tamaño de sus enteros o simplificar su gramática de inicialización de una vez, y no lo hace porque detrás de cada una de esas decisiones hay más código en producción del que cualquiera querría admitir. Motores de videojuegos con quince años de vida, librerías científicas que nadie se atreve a tocar. Renombrar vector o hacer que long mida lo mismo en todas partes rompería buena parte de eso de un plumazo.

Rust resuelve casi todo lo que se critica aquí: un único compilador y gestor de paquetes oficiales, tipos con tamaño fijo desde el primer día, mensajes de error que de verdad ayudan. Pero Rust empezó de cero en 2010, sin cuatro décadas de código heredado a las que rendir cuentas. C++ no tiene ese lujo ni parece que vaya a tenerlo. La complejidad que tanto se le critica no es un accidente de diseño, es el precio que paga por seguir compilando código de hace veinte años sin pestañear, y esa es la razón por la que medio software del planeta, desde el motor de tu navegador hasta el firmware de tu router, sigue escrito en él.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP