Rarezas de C++: dieciséis comportamientos que sorprenden incluso a quien lleva años con el lenguaje. Una variable que en realidad es una función, llaves que cambian el resultado, un map que inserta al leer, vector<bool>, std::move que no mueve nada y el orden de las cosas, todo comprobado con GCC 15.
C++ hereda las rarezas de C (las tienes en Rarezas de C) y añade las suyas, casi todas por la misma razón: el lenguaje ha crecido durante cuarenta años sin romper el código antiguo, y cada novedad tuvo que encajar con todo lo anterior. Estas son las dieciséis sorpresas más conocidas, todas compiladas con GCC 15.3 en C++23 con -Wall -Wextra. Cuando el compilador avisa, también lo enseñamos: muchas de estas trampas las detecta si se lo pides.
Declaraciones e inicialización
1. La «most vexing parse»: una variable que es una función
#include <iostream>
#include <string>
int main()
{
std::string nombre();
std::cout << "compila\n";
}salidacompila
g++: avisovexing.cpp: In function 'int main()':
vexing.cpp:6:23: warning: empty parentheses were disambiguated as a function declaration [-Wvexing-parse]
6 | std::string nombre();
| ^~
vexing.cpp:6:23: note: remove parentheses to default-initialize a variableParece una variable nombre creada con el constructor por omisión, pero C++ tiene una regla heredada de C: si algo puede leerse como una declaración de función, es una declaración de función. std::string nombre(); declara una función sin parámetros que devuelve un std::string. Scott Meyers la bautizó como most vexing parse, «el análisis más irritante». La solución moderna es usar llaves: std::string nombre{}; o, simplemente, std::string nombre;.
2. Llaves o paréntesis: no es lo mismo
#include <print>
#include <vector>
int main()
{
std::vector<int> a(3, 1);
std::vector<int> b{3, 1};
std::println("a = {} b = {}", a, b);
}salidaa = [1, 1, 1] b = [3, 1]
Con paréntesis se llama al constructor «n elementos con este valor»: tres unos. Con llaves, C++ prefiere siempre el constructor que recibe una lista (std::initializer_list), si existe: un vector con un 3 y un 1. La inicialización con llaves se recomienda porque evita conversiones con pérdida, pero con los contenedores hay que saber qué constructor se está llamando.
3. auto con llaves
#include <initializer_list>
#include <print>
#include <type_traits>
int main()
{
auto a{5};
auto b = {5};
std::println("a es int: {}", std::is_same_v<decltype(a), int>);
std::println("b es initializer_list<int>: {}", std::is_same_v<decltype(b), std::initializer_list<int>>);
}salidaa es int: true b es initializer_list<int>: true
Un signo igual cambia el tipo. auto a{5} es un int (desde C++17; antes también era una lista), pero auto b = {5} es una std::initializer_list<int>. La regla práctica: con auto, usa = y un valor normal (auto a = 5;).
4. Los miembros se inicializan en el orden en que se declaran
#include <print>
struct Rango {
int fin;
int inicio;
Rango(int i, int largo) : inicio(i), fin(inicio + largo) {}
};
int main()
{
Rango r(10, 5);
std::println("inicio {}, fin {}", r.inicio, r.fin);
}salida (el valor de fin puede variar)inicio 10, fin 30261
g++: avisoorden_miembros.cpp: In constructor 'Rango::Rango(int, int)':
orden_miembros.cpp:5:9: warning: 'Rango::inicio' will be initialized after [-Wreorder]
5 | int inicio;
| ^~~~~~
orden_miembros.cpp:4:9: warning: 'int Rango::fin' [-Wreorder]
4 | int fin;
| ^~~
orden_miembros.cpp:6:5: warning: when initialized here [-Wreorder]
6 | Rango(int i, int largo) : inicio(i), fin(inicio + largo) {}La lista de inicialización del constructor pone primero inicio, pero da igual: los miembros se construyen en el orden en que están declarados en la clase, y fin va antes. Al calcular fin, inicio aún no tiene valor, y el resultado es basura. -Wall avisa con -Wreorder; la solución es declarar los miembros en el orden en que dependen unos de otros.
Contenedores
5. Leer de un map con [] inserta
#include <map>
#include <print>
#include <string>
int main()
{
std::map<std::string, int> stock{{"manzanas", 4}};
if (stock["peras"] == 0)
std::println("no quedan peras");
std::println("el map tiene {} elementos: {}", stock.size(), stock);
}salidano quedan peras
el map tiene 2 elementos: {"manzanas": 4, "peras": 0}operator[] de std::map y std::unordered_map devuelve una referencia al valor, y si la clave no existe la crea con un valor por omisión para tener algo que devolver. Una simple consulta añade elementos (y por eso [] no se puede usar en un map const). Para consultar sin modificar: find, contains (C++20) o at, que lanza una excepción si la clave no está.
6. vector<bool> no es un vector de bool
#include <print>
#include <type_traits>
#include <vector>
int main()
{
std::vector<int> enteros{1, 0, 1};
std::vector<bool> bits{true, false, true};
auto e = enteros[0];
auto b = bits[0];
std::println("e es int: {} b es bool: {}", std::is_same_v<decltype(e), int>, std::is_same_v<decltype(b), bool>);
b = false;
std::println("bits[0] después de cambiar la copia: {}", bits[0]);
}salidae es int: true b es bool: false bits[0] después de cambiar la copia: false
En 1998 se decidió que std::vector<bool> guardara cada valor en un solo bit para ahorrar memoria. El precio: no puede devolver una referencia a un bool (no se puede apuntar a un bit), sino un objeto «proxy» que se comporta como tal. Con auto, la «copia» b sigue apuntando al vector, y cambiarla cambia el original. Tampoco puedes obtener un bool* a sus datos ni pasarlo a funciones que esperan un vector normal. Si necesitas un vector de verdad, usa std::vector<char> o std::deque<bool>; si quieres bits, std::bitset.
7. size() – 1 con un vector vacío
#include <print>
#include <vector>
int main()
{
std::vector<int> v;
std::println("v.size() - 1 = {}", v.size() - 1);
for (std::size_t i = 0; i + 1 < v.size(); ++i)
std::println("nunca llega aquí");
std::println("con ssize: {}", std::ssize(v) - 1);
}salidav.size() - 1 = 18446744073709551615 con ssize: -1
size() devuelve un std::size_t, un entero sin signo, y 0 menos 1 sin signo da el número más grande posible, no -1. Un bucle for (i = 0; i < v.size() - 1; ++i) con un vector vacío recorre «18 trillones» de posiciones fuera del vector. Las soluciones: escribir la condición con una suma (i + 1 < v.size()) o usar std::ssize (C++20), que devuelve el tamaño con signo.
Tipos y conversiones
8. «hola» + 1 no concatena
#include <iostream>
#include <string>
int main()
{
std::cout << "hola" + 1 << '\n';
std::cout << 'a' + 1 << '\n';
std::string s = std::string("hola") + "!";
std::cout << s << '\n';
}salidaola 98 hola!
Un literal como "hola" no es un std::string, sino un array de char heredado de C, y sumarle 1 es aritmética de punteros: el resultado apunta a la segunda letra. Y 'a' + 1 es un int (98, el código de la «b»). Para concatenar, al menos uno de los dos lados tiene que ser un std::string, o puedes usar el sufijo "hola"s de std::string_literals.
9. División y resto con negativos
#include <print>
int main()
{
std::println("{} {}", 7 / 2, -7 / 2);
std::println("{} {}", 7 % 3, -7 % 3);
std::println("{}", (-7 % 3 + 3) % 3);
}salida3 -3 1 -1 2
La división entera trunca hacia cero (desde C++11 es obligatorio): -7 / 2 da -3, no -4. Y el resto tiene el signo del dividendo: -7 % 3 da -1, no 2 como en Python. Si necesitas el «módulo matemático», por ejemplo para dar la vuelta a un índice circular, hay que corregirlo como en la última línea.
10. 0, NULL y nullptr en una sobrecarga
#include <cstddef>
#include <print>
void f(int) { std::println("f(int)"); }
void f(char *) { std::println("f(char*)"); }
int main()
{
f(0);
f(nullptr);
}salidaf(int) f(char*)
El 0 es un int antes que un puntero, así que f(0) elige la versión de enteros aunque quisieras pasar «ningún puntero». NULL es peor: según la plataforma es 0, 0L o algo propio del compilador, y la llamada puede ser ambigua o elegir el entero. nullptr (C++11) tiene su propio tipo, que solo se convierte a punteros: es la única forma correcta de escribir un puntero nulo en C++.
11. El tamaño de una clase vacía es 1
#include <print>
struct Vacia {};
struct ConMiembro { [[no_unique_address]] Vacia v; int x; };
int main()
{
Vacia a, b;
std::println("sizeof(Vacia) = {}", sizeof(Vacia));
std::println("&a != &b: {}", &a != &b);
std::println("sizeof(ConMiembro) = {}", sizeof(ConMiembro));
}salida en x86-64sizeof(Vacia) = 1 &a != &b: true sizeof(ConMiembro) = 4
Dos objetos distintos tienen que tener direcciones distintas, así que hasta una clase sin nada ocupa al menos un byte. El atributo [[no_unique_address]] (C++20) permite que un miembro vacío no ocupe espacio: lo usan las bibliotecas para guardar, por ejemplo, un asignador de memoria sin estado.
Objetos, herencia y movimiento
12. El «slicing»: copiar un hijo en un padre lo recorta
#include <print>
#include <string>
struct Animal {
virtual ~Animal() = default;
virtual std::string sonido() const { return "..."; }
};
struct Perro : Animal {
std::string sonido() const override { return "guau"; }
};
void hablar(Animal a) { std::println("por valor: {}", a.sonido()); }
void hablar_ref(const Animal& a) { std::println("por referencia: {}", a.sonido()); }
int main()
{
Perro p;
hablar(p);
hablar_ref(p);
}salidapor valor: ... por referencia: guau
Al pasar un Perro por valor a una función que recibe un Animal, se copia solo la parte de Animal: lo propio de Perro se «rebana» y la función virtual ya no sabe que era un perro. Por eso los objetos polimórficos se pasan siempre por referencia o por puntero, como en la lección 7.
13. std::move no mueve nada
#include <print>
#include <string>
#include <utility>
int main()
{
std::string a = "un texto lo bastante largo para no caber en el búfer interno";
auto&& r = std::move(a);
std::println("después de std::move, a sigue con {} caracteres", a.size());
std::string b = std::move(a);
std::println("después de construir b: a tiene {}, b tiene {}", a.size(), b.size());
const std::string c = "constante";
std::string d = std::move(c);
std::println("c sigue con {} caracteres: se copió", c.size());
(void)r;
}salida con libstdc++después de std::move, a sigue con 61 caracteres después de construir b: a tiene 0, b tiene 61 c sigue con 9 caracteres: se copió
std::move es solo una conversión: convierte su argumento en una referencia a un valor temporal (rvalue), que significa «puedes robarle el contenido». Quien mueve de verdad es el constructor o la asignación que recibe ese valor. Por eso, después de std::move(a) a secas, a está intacta; después de construir b, a queda en un estado válido pero sin especificar (con libstdc++, vacía). Y un objeto const no se puede mover: std::move(c) acaba llamando al constructor de copia, sin ningún aviso.
14. const que no lo es tanto: mutable
#include <print>
class Contador {
public:
int valor() const
{
++lecturas;
return 42;
}
int lecturas_hechas() const { return lecturas; }
private:
mutable int lecturas = 0;
};
int main()
{
const Contador c;
c.valor();
c.valor();
std::println("un objeto const se ha leído {} veces", c.lecturas_hechas());
}salidaun objeto const se ha leído 2 veces
Un método const promete no cambiar el objeto, pero un miembro mutable se puede cambiar igualmente. Tiene usos legítimos (cachés, contadores, un std::mutex dentro de una clase que se usa desde varios hilos), pero significa que const en C++ es una promesa sobre el estado «lógico» del objeto, no una garantía de que sus bytes no cambien.
El orden de las cosas
15. El orden en que se evalúan los argumentos no está definido
#include <print>
int siguiente()
{
static int n = 0;
return ++n;
}
void mostrar(int a, int b) { std::println("a = {}, b = {}", a, b); }
int main()
{
mostrar(siguiente(), siguiente());
}salida con GCC 15 en x86-64a = 2, b = 1
¿«a = 1, b = 2»? Con GCC en x86-64 sale al revés: los argumentos de una función se pueden evaluar en cualquier orden, y GCC suele hacerlo de derecha a izquierda. C++17 fijó el orden en algunos casos (a << b, a = b, a.b()), pero no en los argumentos de una llamada. Si el orden importa, guarda cada valor en una variable antes de la llamada.
16. El «fiasco del orden de inicialización estática»
None
#include <string> std::string nombre_app = "decodigo, programación en español";
#include <print>
#include <string>
extern std::string nombre_app;
std::string saludo = "Bienvenido a " + nombre_app;
int main()
{
std::println("[{}]", saludo);
std::println("[{}]", nombre_app);
}g++ -std=c++23 fiasco.cpp config.cpp -o fiasco
salida (depende del orden de enlazado)[Bienvenido a ] [decodigo, programación en español]
Las variables globales de un mismo archivo se construyen en orden, pero entre archivos distintos el orden no está definido: depende de cómo se enlace el programa. Con GCC, los archivos se inicializan en el orden en que aparecen al enlazar: saludo, en fiasco.cpp, se construyó antes que nombre_app, en config.cpp, y leyó una cadena que todavía no existía (técnicamente, comportamiento indefinido: aquí salió vacía, pero podría haber fallado). Con los archivos al revés en la línea de órdenes, sale bien, que es justo lo que lo hace tan traicionero. Curiosamente, con un nombre corto, como "decodigo", tampoco falla: desde C++20 el compilador puede construir esas cadenas cortas al compilar, sin esperar al arranque. Las soluciones: evitar globales que dependan de otras globales, usar una función que devuelva una variable static local (se construye la primera vez que se llama) o, para constantes, constexpr y constinit (C++20), que se inicializan al compilar.
Todo el código de esta página se compiló con GCC 15.3 en x86-64 (C++23, -std=c++23 -Wall -Wextra, con libstdc++) y se ejecutó en un contenedor limpio; las salidas y los avisos están copiados de esa ejecución. Las que dependen de la plataforma o del compilador lo indican.
