1 pontos por GN⁺ 2024-07-06 | 1 comentários | Compartilhar no WhatsApp
  • A inicialização de objetos em C++ pode se dividir em default/value/list/aggregate initialization mesmo com sintaxes parecidas como T t;, T t{};, T t{...}, e o estado real dos membros pode variar
  • Dependendo de onde o construtor padrão é declarado com = default, a ocorrência de zero-initialization muda; se ele for definido fora da classe, os membros podem permanecer com valores não inicializados
  • Mesmo tipos que parecem ter o construtor padrão deletado por conterem um membro const int podem, se forem aggregate, inicializar A a{}; por aggregate initialization e acabar com o valor 0
  • A aggregate initialization baseada em parênteses do C++20 é mais permissiva que a inicialização com chaves, podendo causar diferenças como narrowing conversion e dangling reference
  • Na prática, em vez de depender da combinação de construtores implícitos e regras de inicialização, é mais fácil evitar exceções deixando clara a intenção de inicialização com um construtor explícito

Onde a sintaxe de inicialização do C++ se divide

  • T t; executa default-initialization
    • Se T for um tipo de classe e tiver um construtor padrão, esse construtor é executado
    • Se T for um array, cada elemento é default-initialized
    • Nos demais casos, nada é feito
  • T t{}; parece value-initialization, mas, por causa da sintaxe com chaves, primeiro é tratada como list-initialization
  • Mesmo em exemplos simples, a diferença aparece imediatamente
    • int x{}; é value-initialized e inicializado como 0
    • int y; é default-initialized e seu valor não é inicializado
    • Pair p; e Pair q{};, com construtor padrão, chamam o construtor
    • SimplePair r;, que só tem membros, é default-initialized, e seus membros podem não ser inicializados

O resultado muda conforme onde o construtor padrão é declarado

  • Se o usuário não declarar um construtor, o compilador declara um construtor padrão implicitly-declared
  • Usar T() = default; na primeira declaração dentro da classe é tratado pelo padrão de forma quase igual a um construtor declarado implicitamente
  • Se um construtor padrão declarado implicitamente ou explicitamente defaulted não for deletado, o compilador fornece um implicitly-defined default constructor
    • Na prática, ele deve ser equivalente a T() {}, com corpo vazio e lista de inicialização de membros vazia
  • No código a seguir, t.x se torna 0
struct T {
    int x;
    T() = default;
};
T t{};
std::cout << t.x << std::endl;
  • Isso acontece porque t é value-initialized e, como o construtor padrão de T não é user-provided, primeiro ocorre zero-initialization e depois o construtor padrão é chamado

= default fora da classe e construtor user-provided

  • Mesmo com o mesmo = default, se ele for definido fora da classe, torna-se um construtor user-provided
struct T {
    int x;
    T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
  • Nesse caso, T::T() = default; não foi defaulted na primeira declaração, mas é um construtor definido como defaulted fora da classe
  • As regras de value-initialization executam default-initialization quando há um construtor padrão user-provided ou deleted
  • Portanto, no exemplo acima, apenas o construtor padrão é executado sem zero-initialization; como esse construtor não faz nada, t.x fica com um valor lixo

Casos em que o construtor padrão é deletado

  • Quando o compilador não consegue criar um construtor padrão razoável, o construtor padrão declarado implicitamente pode ser definido como deleted
  • Condições representativas incluem:
    • Há um membro de referência não estático
    • Há um membro não estático ou uma classe-base não abstrata que não pode ser default-constructed ou destruída adequadamente
    • Há um membro const não estático sem inicializador padrão de membro, e esse membro não é const-default-constructible
  • Se o usuário fornecer diretamente um construtor, o compilador não tenta definir um construtor padrão implícito e também não cria um construtor deleted
  • Mesmo com essas condições, as regras de inicialização do C++ se comportam de modo geral de forma muito permissiva

O momento em que a aggregate initialization entra em cena

  • struct A { const int x; }; A a{}; parece que não compilaria porque o construtor padrão seria deletado, mas na prática compila e a.x se torna 0
  • O ponto principal é que A é um aggregate
  • Um aggregate é um array ou uma classe que satisfaz as seguintes condições:
    • Não tem construtores user-declared nem herdados
    • Não tem membros de dados não estáticos diretos private/protected
    • Não tem classes-base diretas private/protected
    • Não tem funções virtuais nem classes-base virtuais
  • Quando list-initialization é aplicada a um aggregate, salvo exceções específicas, aplica-se aggregate initialization
    • Cada elemento da lista de inicialização inicializa, em ordem, cada elemento do aggregate
    • Se o número de elementos da lista for insuficiente, os elementos restantes são inicializados por seus inicializadores padrão de membro, se existirem
    • Se não houver inicializador padrão de membro e não for uma referência, o elemento é copy-initialized com uma lista de inicialização vazia
  • Portanto, A a{}; não é uma chamada de construtor, mas um caso em que a.x é copy-list-initialized com uma lista vazia e passa por value-initialization até chegar a zero-initialization

Regras adicionais da inicialização com chaves

  • Sintaxes baseadas em chaves, como T t{...} e T t = {...}, em geral são list-initialization
    • T t{...} é direct-list-initialization
    • T t = {...} é copy-list-initialization
  • Em um tipo de classe que não é aggregate, se a lista de inicialização estiver vazia e houver um construtor padrão, ocorre value-initialization
  • Caso contrário, os construtores são considerados pela overload resolution comum, e as sobrecargas de construtor com std::initializer_list têm prioridade
  • A inicialização com chaves não permite narrowing conversion ao inicializar com um único elemento
struct A {
    const int x;
};

A b{4};      // possível
A c{4.0f};   // erro de narrowing conversion

A inicialização com parênteses é mais permissiva

  • A inicialização com parênteses, como T t(a, b, c);, chama direct-non-list-initialization e possui regras parecidas com direct-list-initialization, mas diferentes
  • T t(); não é criação de objeto; é interpretado como uma declaração de função sem argumentos e tipo de retorno T, o problema conhecido como most vexing parse
  • A partir do C++20, aggregates também podem usar inicialização com parênteses
  • A aggregate initialization baseada em parênteses se comporta de forma diferente da inicialização baseada em chaves
    • Permite narrowing conversion
    • Os elementos restantes são diretamente value-initialized, não inicializados com lista vazia
    • Não estende o tempo de vida de objetos temporários vinculados a referências
struct T {
    const int& r;
};
T t(42);
  • No código acima, t.r se torna uma dangling reference, e lê-la é undefined behaviour

std::initializer_list, construtor de cópia e elision

  • A inicialização com parênteses pode chamar o construtor de cópia de T mesmo quando há uma sobrecarga de construtor com std::initializer_list<T>
  • Por outro lado, na inicialização com chaves, o construtor std::initializer_list pode ter prioridade
struct T {
    T(std::initializer_list<T>) {
        std::cout << "list" << std::endl;
    }
    T(const T&) {
        std::cout << "copy" << std::endl;
    }
};

T t{};    // list
T s{t};   // list
T r(t);   // copy
T q(T{}); // list, sem cópia
  • O fato de não ocorrer cópia em T q(T{}); se deve à regra de que, em direct- e copy-initialization, se o initializer for um prvalue do tipo T, o objeto é inicializado diretamente por essa expressão
  • Esse comportamento costuma ser conhecido como copy elision, mas o padrão não o chama explicitamente de elision
  • A descrição do padrão é insuficiente quanto a se a mesma elision é possível em list-initialization, e a CWG issue 2311 está relacionada a isso
    • GCC e Clang realizam elision na maioria dos casos
    • Quando há uma sobrecarga de construtor com std::initializer_list<T>, o GCC usa esse construtor em vez de realizar elision

Ordem de avaliação e conclusão prática

  • A ordem de avaliação dos elementos de uma lista de inicialização com parênteses não é garantida
  • A lista de inicialização com chaves avalia os elementos estritamente da esquerda para a direita
  • Há também regras especiais, como inicialização de variáveis estáticas e constant initialization, mas só a inicialização de objetos comuns já torna as regras suficientemente complexas
  • Na prática, é mais seguro escrever construtores explícitos que deixem clara a intenção de inicialização, em vez de depender de construtores padrão implícitos e das regras de inicialização

1 comentários

 
GN⁺ 2024-07-06
Opiniões do Hacker News
  • A explicação de que, ao inicializar por valor T, o resultado vira 0 estava correta; no começo isso passou despercebido, mas o autor de fato estava inicializando x por valor.
    No geral, acho que as regras de inicialização do C++ foram ampliadas e remendadas ao longo de 40 anos, criando cantos difíceis e contraintuitivos, mas em cerca de 99,9% dos casos elas funcionam como se espera.
    Acho que a grande melhoria seria tornar a inicialização padrão explícita e, nos demais casos, sempre fazer inicialização por valor. Quando se quiser evitar o custo de preencher arrays grandes com zeros, seria melhor explicitar isso com uma sintaxe como std::array = void;.

    • Se você de fato inicializa a instância, não passa por esse problema; se quer chamar um construtor, basta definir um construtor.
      Parece um caso de alguém tentando ser esperto demais e depois reclamando que ficou difícil de lidar.
  • A abordagem inteira está errada. Não coloque uma referência const dentro de uma struct; se for realmente necessário, o correto é usar std::reference_wrapper.
    A resposta é um pouco ríspida, mas o ponto central é que a conclusão do texto está completamente errada. Não escreva construtores manualmente; siga a regra dos 5/3/0 e, se precisar manter uma referência const, verifique se está passando um objeto temporário rvalue. Isso não é um problema tão assustador assim.

    • A regra dos 5/3/0 trata de destrutor, construtor de movimento e construtor de cópia. Exceto pelo último exemplo, o texto fala principalmente de comportamentos peculiares do que poderíamos chamar de construtores padrão.
    • Penso o mesmo. Dá muito a impressão de que o autor está ignorando de propósito alguns princípios e diretrizes bem básicos para encontrar motivo de reclamação.
      Mesmo alguém que só passou por tutoriais básicos simplesmente evitaria esses cenários artificiais.
    • Nunca usei std::reference_wrapper, nem me lembro de tê-lo visto em várias bases de código C++ em que trabalhei.
      Ele provavelmente aparece em algum lugar de código de templates profundo e complexo, e a afirmação pode até estar correta, mas, pela minha experiência, isso não é conhecimento comum.
  • O texto original de I Have No Mouth, and I Must Scream (1967) pode ser visto aqui: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...

    • Uma vez falei com Harlan Ellison por telefone. Eu tinha 17 anos, ele ligou para minha casa às 2 da manhã, numa época em que eu puxava um cabo telefônico de 30 pés até o quarto e fechava a porta.
      A família de Martin H Greenberg estava hospedada na nossa casa, e ele estava procurando Martin. Eu gostava de ficção científica e já tinha lido alguns livros dele; depois que ouvi o nome, a conversa ficou quase toda borrada na memória. No fim, acordei Martin e passei o telefone para ele, e depois disso foi difícil voltar a dormir.
  • Fiquei surpreso por não haver nenhum comentário sarcástico sobre a parte em que unless aparece até em negrito.
    Há sintaxes bonitas como T t{v0}; e T t{v0, v1};, mas a inicialização com 1 elemento não se comporta de forma tão confiável quanto nos casos com 2 ou mais elementos.
    É estranho ver essa diferença numa linguagem que caminha para facilitar a criação de structs a partir de packs de parâmetros e que também dá suporte a algo parecido com arrays de tamanho variável inicializáveis desse jeito. Naturalmente, o tipo também pode ser um template.
    Você pode escrever um construtor manualmente, e também pode inicializar uma tupla ou um array fornecendo apenas um elemento, mas, em certos casos especiais, um construtor errado pode ser chamado. Descobri isso quando as listas de inicialização do C++11 tinham acabado de sair e achei uma loucura.

    • Listas de inicialização não importam muito. Quase ninguém as usa, fora alguns contêineres padrão; então dá para simplesmente ignorar esse recurso estranho.
  • Para ver mais esquisitices do C++, recomendo o C++ FQA: https://yosefk.com/c++fqa/
    Ele já tem uns 15 anos, mas, como o C++ quase nunca remove recursos ou comportamentos antigos, ao mesmo tempo continua atemporal.

  • O tema do blog é realmente bonito. Parece claramente inspirado em computadores da era DEC, mas ainda assim é limpo e minimalista, o que dá uma sensação nova.

    • Gosto de como as linhas à direita do título reagem à largura da página e ao número de linhas.
  • Horrível. É uma das partes assustadoras do C++. É ainda mais lamentável porque a linguagem tem recursos excelentes e gente brilhante trabalhando nela.
    Espero que algo como o C++ syntax 2 do Herb transforme isso numa linguagem que pessoas comuns como eu consigam usar.

    • Parece uma reclamação exagerada sobre algo que não é grande problema para qualquer pessoa que já tenha feito um mínimo de desenvolvimento de software.
      Os exemplos do texto foram feitos para atingir deliberadamente casos de borda de um recurso em que a linguagem gera automaticamente, como exceção, funções-membro especiais que o programador optou por não escrever manualmente.
      O C++ tem como objetivo de projeto pagar apenas pelo que se usa, e a regra dos 3/regra dos 5 é conhecimento básico de nível introdutório em C++. Como essas funções-membro especiais só são geradas pelo compilador sob certas condições, também é básico saber que, se as condições não forem atendidas, elas não serão geradas automaticamente.
      No fim, não sei se é tão absurdo assim saber que você precisa configurar diretamente o construtor que vai usar. Também não deveria ser surpreendente que uma instância precise ser inicializada ao ser criada. No mundo real, as pessoas trabalham assim sem fazer grande alarde.
  • Ler isso me dá tontura. Lembro da época em que eu tentava entender construtores e inicialização de objetos em Java
    Pelo menos pela minha experiência até agora, a escolha de não ter construtores especiais, como em Go e Rust, simplifica muita coisa
    Fico curioso se existe alguém que, depois de migrar para uma linguagem sem construtores, sentiu falta deles

    • Há alguns casos meio obscuros de magia que construtores que operam in-place tornam possíveis. Por exemplo, Rust ainda não tem um comportamento do tipo placement new garantido
      Claro, dá para escrever manualmente os campos um a um em MaybeUninit com ptr::write, mas não é ergonômico, e a struct precisa permitir isso explicitamente
    • Não entendo muito bem a ideia de que o compilador não fazer nada “realmente simplifica”
      No fim, isso significa que todos os construtores precisam ser declarados e definidos explicitamente, e C++ oferece essa opção desde o começo. A geração automática de funções-membro especiais é um recurso adicionado para funcionar apenas em casos bem específicos, para quem quer evitar código repetitivo
      Se quiser, basta adicionar seus próprios construtores e levar uma vida normal. Reclamar que funções-membro especiais tornam coisas simples complicadas é parecido com reclamar que o inglês não é simples porque existem palavras difíceis no dicionário
    • Construtores em Java são relativamente fáceis, e essa parte de C++ parece mesmo magia negra
      Pelo que entendo, construtores em Rust não são basicamente parecidos com os de Java?
    • A inicialização zero de Go também às vezes exige ficar analisando as regras da linguagem
      https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
  • Fico curioso se existe alguma ferramenta de C++ que adicione ou mostre todos os comportamentos implícitos que acontecem por trás
    Por exemplo, uma ferramenta que mostre construtores adicionados automaticamente, construtores de cópia implícitos e outras surpresas do tipo

    • Na prática, acho que a melhor opção seria combinar Godbolt com cppinsights
  • No caso de T::T() = default;, o exemplo em que você esperaria que a saída fosse 0, mas acaba sendo lixo, na verdade não é tão estranho assim
    Link relacionado: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
    Isso porque, se fosse feito de outro jeito, qualquer usuário da biblioteca poderia alterar o comportamento dela

    • O fato de a alternativa não fazer sentido não torna automaticamente essa solução sensata. Pelo contrário, mostra em quantos becos sem saída o design de C++ se colocou sozinho