A Vida Começa Aqui — Inicialização de Membros a Fundo

A Vida Começa Aqui — Inicialização de Membros a Fundo

Inicializar membros parece assunto resolvido até aparecerem os casos em que atribuir no corpo do construtor simplesmente não compila. A lista de inicialização, a ordem que segue a declaração e não a lista, os inicializadores no ponto de declaração e a inicialização uniforme com chaves, com sua pegadinha.
Linguagem C++

11 min de leitura

No artigo Projetando Abstrações — Encapsulamento, Invariantes e o Ponteiro this tratamos o construtor como a porta única de entrada de um objeto, mas passamos de raspão por como ele realmente dá vida aos membros. Esse "como" parece básico — afinal, você já inicializa variáveis desde os primeiros dias de C. Só que em C++, com membros que são eles próprios objetos, com ordem de construção, com as chaves {} do C++ moderno e com armadilhas sutis, a inicialização é um daqueles temas onde bugs silenciosos se escondem. Hoje vamos a fundo: a lista de inicialização e por que ela vence a atribuição no corpo, a ordem real em que os membros nascem, os construtores especiais, e a inicialização uniforme com chaves. É uma aula de fundamentos sólidos que vai pagar dividendos em todas as classes que você escrever.

Lista de inicialização versus atribuição no corpo

Vimos no artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática a sintaxe : membro_(valor). Agora o porquê dela ser preferida. Há uma diferença real entre inicializar um membro na lista e atribuí-lo dentro do corpo {} do construtor. Na lista, o membro nasce já com o valor certo. No corpo, o membro é primeiro construído (vazio ou com valor padrão) e depois recebe uma atribuição — dois passos onde poderia haver um. Para um int, a diferença é irrelevante. Para um membro que é um objeto caro de construir, é desperdício:

#include <iostream>
#include <string>

class Exemplo {
public:
    // FORMA BOA: lista de inicialização — nome_ nasce já com o valor.
    Exemplo(const std::string& n) : nome_(n) {}

    // FORMA RUIM (ilustrativa): atribuição no corpo.
    // nome_ é primeiro construído VAZIO, e só então recebe n. Trabalho duplicado.
    // Exemplo(const std::string& n) { nome_ = n; }

private:
    std::string nome_;
};

Há casos em que a lista não é só preferível, mas obrigatória. Membros const e membros que são referências precisam ser inicializados na lista — não podem ser atribuídos depois, porque atribuir a um const ou reapontar uma referência é proibido (artigos Referências e Ponteiros, Frente a Frente e A Disciplina do const — Promessas que o Compilador Cobra):

#include <string>
class Config {
public:
    // 'id_' é const e 'origem_' é referência: SÓ podem ser inicializados na lista.
    Config(int id, const std::string& origem) : id_(id), origem_(origem) {}
private:
    const int id_;                 // const: exige inicialização na lista
    const std::string& origem_;    // referência: idem
};

A regra prática é simples e vale sempre: inicialize todos os membros na lista de inicialização. Não é só otimização; é o que torna certos membros sequer possíveis.

A ordem que importa (e que engana)

Aqui mora uma armadilha que pega gente experiente. Os membros são inicializados na ordem em que são declarados na classe, não na ordem em que aparecem na lista de inicialização. Se você escrever a lista fora de ordem e um membro depender de outro, o resultado será lixo — e o compilador, com -Wall, costuma avisar, mas é bom entender por quê:

#include <iostream>

class Intervalo {
public:
    // PERIGO: a lista sugere que fim_ é inicializado antes de tamanho_,
    // mas a ordem REAL segue a DECLARAÇÃO: inicio_, fim_, tamanho_.
    // Aqui está correto porque tamanho_ (declarado por último) usa inicio_ e fim_,
    // que já nasceram. Se invertêssemos a declaração, quebraria.
    Intervalo(int i, int f) : inicio_(i), fim_(f), tamanho_(fim_ - inicio_) {}
    void mostra() const {
        std::cout << "[" << inicio_ << ", " << fim_ << "] tam=" << tamanho_ << '\n';
    }
private:
    int inicio_;   // declarado 1º → inicializado 1º
    int fim_;      // declarado 2º → inicializado 2º
    int tamanho_;  // declarado 3º → inicializado 3º (pode usar os anteriores)
};

int main() {
    Intervalo iv(10, 25);
    iv.mostra();   // [10, 25] tam=15
    return 0;
}

A lição: mantenha a ordem da lista de inicialização igual à ordem de declaração dos membros, e evite que um membro dependa de outro na construção quando puder. Isso elimina uma categoria inteira de bugs sutis.

Inicializadores de membro no ponto de declaração

O C++11 trouxe um recurso que reduz repetição: você pode dar um valor padrão ao membro na própria declaração. Se um construtor não o inicializar, esse padrão vale; se inicializar, o do construtor vence. Isso evita repetir o mesmo valor inicial em vários construtores:

#include <iostream>

class Jogador {
public:
    Jogador() = default;                       // usa os padrões da declaração
    Jogador(int v) : vidas_(v) {}              // sobrescreve só vidas_

    void status() const {
        std::cout << "vidas=" << vidas_ << " pontos=" << pontos_ << " nivel=" << nivel_ << '\n';
    }
private:
    int vidas_  = 3;   // padrão na declaração
    int pontos_ = 0;
    int nivel_  = 1;
};

int main() {
    Jogador a;          // vidas=3 pontos=0 nivel=1 (todos padrão)
    Jogador b(5);       // vidas=5 pontos=0 nivel=1 (só vidas mudou)
    a.status();
    b.status();
    return 0;
}

O = default que você vê no primeiro construtor pede ao compilador para gerar o construtor padrão automaticamente — útil quando você quer o comportamento gerado mas precisa declará-lo explicitamente (por exemplo, porque também definiu outro construtor). Esses inicializadores no ponto de declaração são um ótimo hábito: cada membro tem um valor sensato garantido, e nenhum construtor pode acidentalmente deixar um membro sem inicializar.

Inicialização uniforme com chaves e uma armadilha

O C++11 introduziu a inicialização com chaves {}, buscando uma sintaxe única para tudo. Ela tem duas vantagens concretas sobre os parênteses. Primeira, proíbe conversões estreitas (narrowing) — tentar enfiar um double num int, por exemplo, vira erro em vez de truncar silenciosamente:

int a(3.9);    // compila: trunca para 3 (silencioso, perigoso)
int b{3.9};    // ERRO: {} recusa a conversão estreita de double para int

Segunda, resolve um problema clássico de análise sintática (o most vexing parse): Widget w(); parece criar um objeto, mas o compilador o lê como a declaração de uma função w que retorna Widget. Com chaves, Widget w{}; é inequivocamente um objeto. A honestidade sobre a armadilha: quando um tipo tem um construtor que aceita std::initializer_list (como os containers da STL), as chaves preferem esse construtor, o que pode surpreender. O exemplo canônico é std::vector:

#include <vector>
#include <iostream>
int main() {
    std::vector<int> a(5, 0);   // parênteses: 5 elementos, todos 0
    std::vector<int> b{5, 0};   // chaves: 2 elementos, [5, 0] — initializer_list!
    std::cout << a.size() << ' ' << b.size() << '\n';   // 5 2
    return 0;
}

A regra prática que vou recomendar: prefira chaves {} por padrão, pela segurança contra narrowing e contra o most vexing parse, mas conheça a exceção dos containers, onde () e {} significam coisas diferentes. Quando o número e o valor dos elementos importam, seja explícito sobre qual você quer.

A lista de inicialização não é preferência de estilo: ela constrói o membro uma vez, enquanto atribuir no corpo constrói e depois sobrescreve — diferença que passa de detalhe a obrigação quando o membro é const, uma referência, ou um tipo sem construtor padrão. A ordem tem uma pegadinha própria, e vale guardá-la: os membros nascem na ordem em que foram declarados na classe, não na ordem em que aparecem na lista, e o compilador avisa disso apenas se você pedir.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/language/constructor: as regras completas de construtores, listas de inicialização e a ordem de inicialização dos membros.
  • cppreference.com/w/cpp/language/initialization: o panorama de todas as formas de inicialização em C++, incluindo a uniforme com chaves.
  • Scott Meyers, Effective C++ (3ª ed.), Item 4 ("Make sure objects are initialized before they're used") e Item 13 sobre ordem: os fundamentos desta aula.
  • Scott Meyers, Effective Modern C++ (2014), Item 7 ("Distinguish between () and {} when creating objects"): a discussão definitiva da armadilha do initializer_list.
  • ISO C++ Core Guidelines, regras C.41 ("A constructor should create a fully initialized object") e C.47 (ordem de inicialização dos membros): as diretrizes oficiais.

Exercícios

Exercício 1

Reescreva a classe abaixo, que usa atribuição no corpo, para usar lista de inicialização. Explique qual é a vantagem concreta no caso do membro std::string.

#include <string>
class Livro {
public:
    Livro(const std::string& t, int p) { titulo_ = t; paginas_ = p; }
private:
    std::string titulo_;
    int paginas_;
};
Ver resposta

✓ Resposta: Com lista de inicialização:

#include <string>
class Livro {
public:
    Livro(const std::string& t, int p) : titulo_(t), paginas_(p) {}
private:
    std::string titulo_;
    int paginas_;
};

A vantagem concreta no std::string titulo_: com a lista, titulo_ é construído diretamente como cópia de t, num único passo. Com a atribuição no corpo, titulo_ primeiro seria construído vazio (uma construção de string) e depois receberia t (uma atribuição de string) — duas operações onde uma basta. Para strings grandes ou muitos objetos, isso é desperdício mensurável.

Exercício 2

A classe a seguir tem um membro const e um membro referência inicializados no corpo, o que não compila. Corrija-a e explique por que esses membros exigem a lista de inicialização.

#include <string>
class Sessao {
public:
    Sessao(int id, const std::string& user) {
        id_ = id;         // erro
        usuario_ = user;  // erro
    }
private:
    const int id_;
    const std::string& usuario_;
};
Ver resposta

✓ Resposta: A correção move ambos para a lista:

#include <string>
class Sessao {
public:
    Sessao(int id, const std::string& user) : id_(id), usuario_(user) {}
private:
    const int id_;
    const std::string& usuario_;
};

Membros const e referências exigem a lista porque só podem ser inicializados, nunca atribuídos: atribuir a um const violaria sua imutabilidade, e "atribuir" a uma referência (usuario_ = user) não a religa, apenas copia valor sobre o objeto referido — que nem existe ainda no corpo. A lista de inicialização é o único momento em que esses membros podem ser ligados ao seu valor.

Exercício 3

Sem compilar, diga o que o construtor abaixo faz de errado e por que -Wall emitiria um aviso. Qual valor dobro_ recebe?

class Bug {
public:
    Bug(int x) : dobro_(base_ * 2), base_(x) {}   // ordem suspeita...
private:
    int base_;
    int dobro_;
};
Ver resposta

✓ Resposta: O valor sai correto — dobro_ recebe x * 2 —, mas por sorte, e o aviso existe para lembrar disso. Os membros são inicializados na ordem de declaração, nunca na ordem em que aparecem na lista: como base_ é declarado antes de dobro_, ele nasce primeiro com x, e só depois dobro_ calcula base_ * 2 sobre um valor já válido. O -Wall (via -Wreorder) avisa justamente porque a lista está escrita na ordem inversa da declaração, sugerindo uma sequência que não é a real. O código é frágil: basta alguém trocar a ordem dos dois campos na declaração para que dobro_ passe a ler base_ ainda não inicializado, e o resultado vire lixo silenciosamente. A correção é alinhar a lista à declaração: : base_(x), dobro_(base_ * 2).

Exercício 4

Escreva uma classe Config com três membros (int porta, bool debug, std::string host) usando inicializadores no ponto de declaração para dar padrões sensatos, mais um construtor = default e outro que permita sobrescrever só a porta. Demonstre os dois.

Ver resposta

✓ Resposta: A classe:

#include <iostream>
#include <string>
class Config {
public:
    Config() = default;                 // usa todos os padrões
    Config(int p) : porta(p) {}         // sobrescreve só a porta
    void mostra() const {
        std::cout << "host=" << host << " porta=" << porta
                  << " debug=" << (debug ? "on" : "off") << '\n';
    }
private:
    int         porta = 8080;           // padrões no ponto de declaração
    bool        debug = false;
    std::string host  = "localhost";
};
int main() {
    Config padrao;                      // host=localhost porta=8080 debug=off
    Config custom(9090);                // host=localhost porta=9090 debug=off
    padrao.mostra();
    custom.mostra();
    return 0;
}

Os inicializadores na declaração garantem que qualquer construtor produza um objeto totalmente inicializado; o = default aproveita esses padrões, e o construtor de um argumento muda apenas porta.

Exercício 5

Explique a diferença de resultado entre std::vector<int> a(3, 7); e std::vector<int> b{3, 7};. Depois, mostre um caso em que usar {} em vez de () evita um bug (narrowing), e um caso em que {} causa uma surpresa (container), justificando a regra prática da aula.

Ver resposta

✓ Resposta: std::vector<int> a(3, 7); usa parênteses e chama o construtor "3 cópias do valor 7" → vetor [7, 7, 7], tamanho 3. std::vector<int> b{3, 7}; usa chaves, que preferem o construtor initializer_list → vetor com os elementos literais [3, 7], tamanho 2. Caso em que {} evita bug (narrowing):

int x{3.9};   // ERRO de compilação — impede o truncamento silencioso
// int x(3.9); // compilaria, truncando para 3 sem avisar

Caso em que {} surpreende (container): o próprio std::vector<int> b{3, 7} acima, que a pessoa poderia esperar ser "3 setes" e é na verdade "[3, 7]". A regra prática: use {} por padrão (ganha a proteção contra narrowing e contra o most vexing parse), mas nos containers da STL lembre que {} significa "estes elementos" e () significa "estes argumentos de construção", e escolha conscientemente.

Comentários

Mais em Linguagem C++

A Filosofia por Trás do C++ e a Saída Elegante do printf
A Filosofia por Trás do C++ e a Saída Elegante do printf

Três detalhes aparecem na primeira linha de qualquer programa C++: o iostream…

O Destino e o Novo Começo — Encerrando a Jornada Completa
O Destino e o Novo Começo — Encerrando a Jornada Completa

O fim de cinquenta e nove artigos, e o balanço do que mudou: não a lista de…

O Capstone dos Jogos — Breakout e o Fim da Jornada
O Capstone dos Jogos — Breakout e o Fim da Jornada

O último jogo reúne tudo o que a trilha construiu: recursos em RAII, entidades…