</>waridocu

C++

Declaración e inicialización a fondo

Las cinco formas de darle un valor inicial a una variable en C++, cuál elegir y qué trampa esconde cada una.

cppinicializacionllavesnarrowingmost-vexing-parse

De dónde venimos

En Variables, tipos de datos y operadores aparecieron, sin explicación, dos formas distintas de escribir lo mismo:

Recordando: las dos formas de la página 3cpp
int edad = 30;                  // inicialización con =
double altura{1.75};            // inicialización con llaves

Ahí se dijo “con llaves, C++11 en adelante” y se siguió adelante, porque no había con qué contrastarlo. Ahora sí lo hay: en el camino aparecieron int notas[5] = {7, 4, 6, 3, 5}; en arrays, Punto p{3.0, 4.0}; en structs, Contador c(0); y : valor_(inicial) en clases.

Son cinco sintaxis para una misma idea —darle un valor de partida a algo— y no son equivalentes. Una acepta en silencio conversiones que pierden datos, otra las rechaza, y otra puede no crear el objeto que aparenta crear. Esta página las ordena.

Importante

Declarar e inicializar no son lo mismo, aunque casi siempre se escriban en la misma línea. Declarar es decirle al compilador “existe una variable llamada x de tipo int”: le reserva un espacio en memoria. Inicializar es poner el primer valor dentro de ese espacio. Puedes declarar sin inicializar (int x;), y ahí está la fuente del problema que esta página empieza resolviendo.

Las cinco formas de inicializar

Ejemplo: las cinco formas de inicializar, una al lado de la otracpp
#include <iostream>

int main() {
    int a;          // default-init: sin valor definido
    int b = 5;      // copy-init
    int c(5);       // direct-init
    int d{5};       // list-init (o brace-init)
    int e{};        // value-init: queda en 0

    a = 5;          // le damos un valor ANTES de leerla

    std::cout << a << " " << b << " " << c << " " << d << " " << e << "\n";
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o formas formas.cpp
./formas
texttext
5 5 5 5 0

Todas terminan con un 5 (salvo la última, que da 0), lo cual esconde justamente lo que las diferencia. Vamos una por una.

Default-init

Ejemplo: default-initcpp
int x;              // NO queda inicializada: contiene basura
std::string s;      // sí queda inicializada, con la cadena vacía

Inicialización por defecto: declarar sin dar ningún valor. Lo que ocurre depende del tipo:

  • Para tipos fundamentales (int, double, char, bool, punteros) no se hace nada: la variable contiene lo que hubiera antes en esa zona de memoria. Leerla es comportamiento indefinido, la advertencia que ya viste en Variables y tipos y, en su versión más peligrosa, en punteros no inicializados.
  • Para tipos con constructor por defecto (std::string, tus propias clases) sí pasa algo: se ejecuta ese constructor. Por eso std::string s; es perfectamente seguro — nace vacío, no con basura.

Por eso int x; es un error latente y std::string s; no lo es, aunque se escriban igual. Es la asimetría más confusa de C++ para quien empieza.

Tip

Regla práctica para no tener que recordar la asimetría: nunca declares una variable sin darle un valor. Si aún no sabes cuál, usa int x{}; (value-init, que garantiza el 0). La única excepción legítima es cuando la variable se llena inmediatamente después, por ejemplo con std::cin >> nota;.

Copy-init

Ejemplo: copy-initcpp
int x = 5;
std::string nombre = "Camilo";

Inicialización por copia: la forma con =, heredada de C y la más común en código existente. El nombre viene de que conceptualmente “copia” el valor de la derecha dentro de la variable.

Es cómoda y legible, pero es la más permisiva de todas: acepta conversiones que pierden información sin decir nada. Ese es el tema de la sección siguiente.

Direct-init

Ejemplo: direct-initcpp
int x(5);
Contador c(0);                          // llamando al constructor de una clase
CuentaBancaria cuenta("Ana", 1000.0);

Inicialización directa: con paréntesis. Con tipos fundamentales es equivalente a = 5, pero con clases se lee como lo que realmente es: una llamada al constructor con esos argumentos.

Es la forma clásica de construir objetos, y la que usan casi todos los ejemplos de C++ anteriores a C++11. Tiene un problema propio y espectacular, el most vexing parse, que veremos más abajo.

List-init (o brace-init)

Ejemplo: list-initcpp
int x{5};
double altura{1.75};
Punto p{3.0, 4.0};
int notas[5] = {7, 4, 6, 3, 5};

Inicialización con llaves, introducida en C++11 y también llamada inicialización uniforme porque funciona igual para todo: tipos fundamentales, structs, arrays, clases con constructor y contenedores de la Standard Library. Una sola sintaxis para todos los casos, en lugar de una distinta para cada uno.

Es la forma recomendada en C++ moderno, y la sección Por qué se prefiere la inicialización con llaves explica el motivo concreto.

Value-init

Ejemplo: value-init, el "valor cero" de cada tipocpp
int x{};            // 0
double d{};         // 0.0
bool b{};           // false
char c{};           // '\0'
int* p{};           // nullptr
std::string s{};    // cadena vacía
Punto origen{};     // x = 0.0, y = 0.0

Inicialización por valor: llaves vacías. Es un caso particular de la anterior, con una garantía muy útil: deja el objeto en su “valor cero”, sea cual sea el tipo. Para los fundamentales eso es literalmente cero; para una clase, ejecuta su constructor por defecto; para un struct, pone todos sus miembros en cero.

Es la forma de escribir “todavía no tengo un valor, pero quiero un estado definido” sin tener que recordar cuál es el cero de cada tipo.

Por qué se prefiere la inicialización con llaves

Aquí está la razón práctica, y es una sola: las llaves rechazan las conversiones que pierden información (lo que el estándar llama narrowing, “estrechamiento”). Las otras formas las aceptan calladas.

Ejemplo: qué guarda de verdad int x = 3.7;cpp
#include <iostream>

int main() {
    int truncado = 3.7;                     // copy-init: convierte y trunca en silencio
    std::cout << truncado << "\n";          // 3, ni 3.7 ni 4

    // int estricto{3.7};                   // ERROR de compilación: narrowing conversion
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o narrowing narrowing.cpp
./narrowing
texttext
3

Ni -Wall protesta: int truncado = 3.7; es código perfectamente legal que descarta la parte decimal, exactamente igual que la división entera convertía 7 / 2 en 3. Trunca, no redondea.

Ahora descomenta la línea de las llaves y el programa deja de compilar:

texttext
error: narrowing conversion of '3.7000000000000002e+0' from 'double' to 'int' [-Wnarrowing]
Advertencia

Esta es una trampa real, no un ejercicio de laboratorio. int total = precio * cantidad; donde precio es un double compila sin una sola advertencia y pierde los centavos de cada operación. El error no aparece nunca: simplemente los números no cuadran, y encontrar dónde se perdieron cuesta horas. Con int total{precio * cantidad}; el compilador te detiene en el acto y te obliga a decidir de forma explícita —redondear, usar double, o convertir a propósito con static_cast<int>— en vez de dejar que el lenguaje decida por ti.

El mismo mecanismo protege en muchos otros casos: meter un long en un int, un int grande en un char, o un double en un float. Todos ellos pasan sin ruido con = y se detienen con {}.

Nota

“Pierde información” se juzga por el tipo, no solo por el valor concreto. Pasar de un tipo con decimales a un entero (doubleint) siempre se rechaza con llaves, aunque el número sea redondo: int a{2.0}; tampoco compila. Al revés (double d{5};) sí se permite, porque un 5 entero cabe exacto en un double y no se pierde nada.

Advertencia

La inicialización uniforme tiene una excepción famosa que conviene conocer antes de encontrártela: en los contenedores de la Standard Library, {} y () significan cosas distintas.

Ejemplo: dos vectores casi idénticos con contenidos muy distintoscpp
#include <iostream>
#include <vector>

int main() {
    std::vector<int> conParentesis(3);   // tres elementos, todos 0
    std::vector<int> conLlaves{3};       // UN elemento, cuyo valor es 3

    std::cout << conParentesis.size() << " " << conLlaves.size() << "\n";
    return 0;
}
texttext
3 1

Con paréntesis se llama al constructor “dame N elementos”; con llaves se interpreta como “esta es la lista de elementos”. Cómo y por qué ocurre esto se retoma en la sección Standard Library de esta documentación, junto con std::vector. Por ahora, quédate con la consecuencia: para contenedores, () describe el tamaño y {} describe el contenido.

El most vexing parse

El most vexing parse (“el análisis sintáctico más irritante”) es la trampa clásica de la inicialización directa, y tiene nombre propio porque le pasa a todo el mundo al menos una vez.

Ejemplo: tres declaraciones que parecen lo mismo, y solo dos construyencpp
#include <iostream>

class Widget {
public:
    Widget() { std::cout << "Widget construido\n"; }
};

int main() {
    Widget a;        // construye un Widget
    Widget b{};      // construye un Widget
    Widget c();      // NO construye nada: declara una función c()
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o vexing vexing.cpp
./vexing
texttext
Widget construido
Widget construido

Dos líneas, no tres. Widget c(); no crea ningún objeto: para el compilador es la declaración de una función llamada c, que no toma argumentos y devuelve un Widget — exactamente la misma forma que el prototipo double areaRectangulo(double, double);. Y como es una declaración válida, no hay error: el programa compila y corre, solo que c no es lo que creías.

La regla que aplica el compilador es “ante la duda entre una declaración de función y la definición de un objeto, es una declaración de función”. Por eso la línea intermedia del ejemplo es la solución: Widget b{}; no puede confundirse con una función, porque ninguna declaración de función usa llaves ahí.

Advertencia

El síntoma es desconcertante: el constructor no se ejecuta (falta un mensaje, un archivo no se abre, un contador arranca sin inicializar) y después, al intentar usar el objeto, aparece un error que no menciona nada de esto, del estilo request for member 'algo' in 'c', which is of non-class type 'Widget()'. Si alguna vez lees “of non-class type” seguido de algo con paréntesis, busca un () de más en la declaración.

Nota

Desde la versión 13, g++ avisa de este caso por su cuenta con -Wvexing-parse, incluso sin -Wall:

texttext
warning: empty parentheses were disambiguated as a function declaration
note: remove parentheses to default-initialize a variable
note: or replace parentheses with braces to value-initialize a variable

El aviso solo aparece con paréntesis vacíos. La versión con argumentos (Widget c(Otro());) también puede caer en el mismo problema y ahí no hay advertencia. Con compiladores más antiguos, ni siquiera el caso vacío avisa.

Fíjate en el detalle que hace útil todo esto: Widget a; y Widget b{}; funcionan, Widget c(); no. Los paréntesis solo se vuelven ambiguos cuando están vacíos; Contador c(0); con un argumento no tiene este problema, porque un 0 no puede ser un tipo de parámetro.

Inicialización de agregados: structs y arrays

Un agregado es un tipo sin constructores propios ni miembros privados: básicamente, los structs de datos simples y los arrays que ya conoces. Para ellos, las llaves tienen un significado extra: enumeran los valores de sus elementos, en el mismo orden en que están declarados.

Ejemplo: llaves para arrays y structscpp
#include <iostream>
#include <string>

struct Persona {
    std::string nombre;
    int edad;
};

int main() {
    int notas[5] = {7, 4, 6};       // los dos que faltan quedan en 0
    int ceros[3]{};                 // los tres en 0

    Persona ana{"Ana", 29};         // en el orden de declaración de los miembros
    Persona vacia{};                // nombre vacío, edad 0

    std::cout << notas[2] << " " << notas[4] << "\n";
    std::cout << ceros[0] << "\n";
    std::cout << ana.nombre << " " << ana.edad << "\n";
    std::cout << "[" << vacia.nombre << "] " << vacia.edad << "\n";
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o agregados agregados.cpp
./agregados
texttext
6 0
0
Ana 29
[] 0

Tres reglas que se leen directamente de esa salida:

  1. Si das menos valores de los que hay, el resto se inicializa a cero. notas[4] vale 0 aunque nunca lo escribiste. Es la garantía que mencionaba el consejo de structs sobre preferir Punto p{3.0, 4.0}; a asignar campo por campo.
  2. {} vacías ponen todo en cero, tanto en un array (ceros) como en un struct (vacia). Es la value-init de antes, aplicada a cada elemento.
  3. El orden importa y es el de la declaración, no el de los nombres. Persona ana{29, "Ana"}; no compila: los tipos no coinciden. Y si dos miembros fueran del mismo tipo, compilaría con los valores cruzados sin decir nada.
Advertencia

int notas[5] = {7, 4, 6}; rellena con ceros, pero int notas[5]; no: ahí no hay llaves, es default-init, y los cinco elementos contienen basura. La diferencia entre “faltan valores en la lista” (se completan con cero) y “no hay lista” (no se inicializa nada) es total, y visualmente casi invisible.

Nota

En cuanto un struct gana un constructor propio o un miembro privado deja de ser un agregado, y Persona ana{"Ana", 29}; pasa a significar “llama al constructor que acepte un string y un int” en vez de “asigna miembro a miembro”. Normalmente el resultado es el mismo, pero si no existe tal constructor, el código deja de compilar. Es el momento en que una clase deja de comportarse como un contenedor de datos y empieza a comportarse como un tipo con reglas propias.

La lista de inicialización de un constructor

Hay una sexta forma de dar valores iniciales que no aparece en la lista de arriba porque no se escribe en una declaración de variable, sino dentro de una clase: la lista de inicialización de miembros de un constructor.

Ejemplo: la lista de inicialización, vista como una forma más de inicializarcpp
Persona(const std::string& n, int e) : nombre_(n), edad_(e) {}

Encaja aquí porque hace exactamente lo mismo que las cinco formas anteriores, solo que aplicado a los miembros del objeto que se está creando: cada miembro(valor) inicializa, no asigna. Esa es la diferencia que hacía preferible la lista frente a asignar en el cuerpo del constructor — y el mismo vocabulario de esta página lo explica: en la lista hay una sola inicialización; en el cuerpo hay una default-init seguida de una asignación.

También puede escribirse con llaves, con las mismas garantías contra el narrowing que en cualquier otro sitio:

Ejemplo: la misma lista, con llaves en vez de paréntesiscpp
Persona(const std::string& n, int e) : nombre_{n}, edad_{e} {}

Tabla resumen

FormaSintaxisCuándo usarlaTrampa a evitar
Default-initint x;Casi nunca; solo si la llenas en la línea siguienteCon tipos fundamentales queda con basura, no con 0
Copy-initint x = 5;Código heredado y casos simples y obviosAcepta conversiones que pierden datos sin avisar
Direct-initContador c(0);Llamar a un constructor con argumentosCon paréntesis vacíos, Widget c(); declara una función
List-initint x{5};Por defecto, en C++ moderno, para todoEn contenedores, {3} es un elemento y (3) un tamaño
Value-initint x{};Cuando quieres un estado definido y aún no un valorConfundirla con default-init: aquí sí hay garantía de cero
Lista de miembros: x_(v)Siempre, para inicializar atributos de una claseEl orden real es el de declaración de los miembros
Tip

Si quieres una sola regla que te sirva el 95 % del tiempo: usa siempre {}, y baja a () solo cuando necesites un constructor que las llaves interpretarían como lista de elementos (el caso de std::vector<int> v(3);). Es la recomendación estándar de la comunidad de C++ desde C++11, y su valor no es estético: convierte errores silenciosos de conversión en errores de compilación.

Siguiente paso

Entre esta página y la anterior, el mismo puñado de símbolos ha ido cambiando de significado según dónde aparezca: unos paréntesis pueden ser una llamada, unos parámetros o una construcción; unas llaves pueden ser un bloque, un cuerpo de clase o una lista de valores. Para cerrar Fundamentos, Repaso de sintaxis: (), {}, [] y : reúne todos esos usos en un solo lugar, listo para consultar cuando veas un símbolo y no recuerdes qué significa en ese contexto.