1 puntos por GN⁺ 2024-07-06 | 1 comentarios | Compartir por WhatsApp
  • En C++, la inicialización de objetos puede dividirse en default/value/list/aggregate initialization incluso con sintaxis parecidas como T t;, T t{}; o T t{...}, lo que puede cambiar el estado real de los miembros
  • Según dónde se escriba = default para el constructor por defecto, cambia si ocurre o no zero-initialization; si se define fuera de la clase, los miembros pueden quedar con valores no inicializados
  • Incluso un tipo que parece que tendría el constructor por defecto eliminado por tener un miembro const int puede inicializarse con 0 mediante aggregate initialization si es un aggregate, como en A a{};
  • La aggregate initialization basada en paréntesis de C++20 es más permisiva que la inicialización con llaves, por lo que puede generar diferencias como narrowing conversion y dangling references
  • En la práctica, es más fácil evitar excepciones expresando la intención de inicialización con un constructor explícito, en lugar de depender de la combinación de constructores implícitos y reglas de inicialización

Dónde se bifurca la sintaxis de inicialización en C++

  • T t; realiza default-initialization
    • Si T es un tipo de clase y tiene un constructor por defecto, ejecuta ese constructor
    • Si T es un arreglo, default-initialize cada elemento
    • En los demás casos, no hace nada
  • T t{}; parece value-initialization, pero por la sintaxis con llaves se procesa primero como list-initialization
  • Incluso en ejemplos simples, la diferencia aparece de inmediato
    • int x{}; se value-initializa y queda inicializado en 0
    • int y; se default-initializa y su valor no queda inicializado
    • Pair p; y Pair q{};, con constructor por defecto, llaman al constructor
    • SimplePair r;, que solo tiene miembros, se default-initializa y sus miembros pueden quedar sin inicializar

El resultado cambia según dónde se declare el constructor por defecto

  • Si el usuario no declara un constructor, el compilador declara un constructor por defecto implicitly-declared
  • Si T() = default; se escribe como primera declaración dentro de la clase, el estándar lo trata de forma casi similar a un constructor declarado implícitamente
  • Si un constructor por defecto declarado implícitamente o explícitamente defaulted no se elimina, el compilador proporciona un implicitly-defined default constructor
    • En términos de implementación, debe ser equivalente a T() {} con cuerpo vacío y lista vacía de inicialización de miembros
  • En el siguiente código, t.x se vuelve 0
struct T {
    int x;
    T() = default;
};
T t{};
std::cout << t.x << std::endl;
  • Esto se debe a que t se value-initializa y, como el constructor por defecto de T no es user-provided, primero ocurre zero-initialization y luego se llama al constructor por defecto

= default fuera de la clase y constructores user-provided

  • Aunque sea el mismo = default, si se define fuera de la clase se convierte en un constructor user-provided
struct T {
    int x;
    T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
  • En este caso, T::T() = default; no fue defaulted en su primera declaración, sino un constructor definido como defaulted fuera de la clase
  • Las reglas de value-initialization realizan default-initialization si existe un constructor por defecto user-provided o deleted
  • Por lo tanto, en el ejemplo anterior solo se ejecuta el constructor por defecto sin zero-initialization y, como ese constructor no hace nada, t.x queda con un valor basura

Casos en los que se elimina el constructor por defecto

  • Si el compilador no puede crear un constructor por defecto razonable, el constructor por defecto declarado implícitamente puede definirse como deleted
  • Algunas condiciones típicas son las siguientes
    • Cuando hay un miembro de referencia no estático
    • Cuando hay un miembro no estático o una clase base no abstracta que no puede default-construct o destruct adecuadamente
    • Cuando hay un miembro const no estático sin inicializador de miembro por defecto y ese miembro no es const-default-constructible
  • Si el usuario proporciona directamente un constructor, el compilador no intenta definir un constructor por defecto implícito y tampoco crea un constructor deleted
  • Incluso con estas condiciones, las reglas de inicialización de C++ se comportan en general de forma muy permisiva

El momento en que interviene aggregate initialization

  • struct A { const int x; }; A a{}; parece que no compilaría porque el constructor por defecto estaría eliminado, pero en realidad compila y a.x queda en 0
  • La clave es que A es un aggregate
  • Un aggregate es un arreglo o una clase que cumple estas condiciones
    • No tiene constructores user-declared ni heredados
    • No tiene miembros de datos no estáticos directos private/protected
    • No tiene clases base directas private/protected
    • No tiene funciones virtuales ni clases base virtuales
  • Cuando se realiza list-initialization sobre un aggregate, se aplica aggregate initialization, salvo excepciones específicas
    • Cada elemento de la lista de inicialización inicializa, en orden, cada elemento del aggregate
    • Si faltan elementos en la lista, los elementos restantes se inicializan con su inicializador de miembro por defecto, si lo tienen
    • Si no tienen inicializador de miembro por defecto y no son referencias, se copy-initializan con una lista de inicialización vacía
  • Por lo tanto, A a{}; no es una llamada a constructor, sino un caso en el que a.x se copy-list-initializa con una lista vacía y, pasando por value-initialization, llega a zero-initialization

Reglas adicionales de la inicialización con llaves

  • La sintaxis basada en llaves como T t{...} y T t = {...} suele ser list-initialization
    • T t{...} es direct-list-initialization
    • T t = {...} es copy-list-initialization
  • En tipos de clase que no son aggregates, si la lista de inicialización está vacía y hay un constructor por defecto, se realiza value-initialization
  • En los demás casos, se consideran los constructores mediante la overload resolution normal, y las sobrecargas de constructores con std::initializer_list tienen prioridad
  • La inicialización con llaves no permite narrowing conversion al inicializar con un solo elemento
struct A {
    const int x;
};

A b{4};      // posible
A c{4.0f};   // error de narrowing conversion

La inicialización con paréntesis es más permisiva

  • La inicialización con paréntesis como T t(a, b, c); invoca direct-non-list-initialization y, aunque se parece a direct-list-initialization, tiene reglas distintas
  • T t(); no se interpreta como creación de un objeto, sino como una declaración de función sin argumentos cuyo tipo de retorno es T, un problema conocido como most vexing parse
  • Desde C++20, también se puede usar inicialización con paréntesis en aggregates
  • La aggregate initialization basada en paréntesis se comporta de forma distinta a la basada en llaves
    • Permite narrowing conversion
    • Los elementos restantes se value-initializan directamente, no mediante inicialización con lista vacía
    • No extiende la vida de los objetos temporales enlazados a referencias
struct T {
    const int& r;
};
T t(42);
  • En el código anterior, t.r se convierte en una dangling reference y leerla es undefined behaviour

std::initializer_list, constructor de copia y elision

  • La inicialización con paréntesis puede llamar al constructor de copia de T incluso cuando existe una sobrecarga de constructor con std::initializer_list<T>
  • En cambio, en la inicialización con llaves puede tener prioridad el constructor con std::initializer_list
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, sin copia
  • Que no ocurra copia en T q(T{}); se debe a la regla según la cual, en direct- y copy-initialization, si el initializer es un prvalue de tipo T, el objeto se inicializa directamente con esa expresión
  • Este comportamiento suele conocerse como copy elision, pero el estándar no lo llama explícitamente elision
  • No hay suficiente explicación en el estándar sobre si la misma elision es posible en list-initialization, y CWG issue 2311 está relacionado
    • GCC y Clang realizan elision en la mayoría de los casos
    • Si existe una sobrecarga de constructor con std::initializer_list<T>, GCC usa ese constructor en lugar de hacer elision

Orden de evaluación y conclusión práctica

  • El orden de evaluación de los elementos de una lista de inicialización con paréntesis no está garantizado
  • Las listas de inicialización con llaves evalúan estrictamente los elementos de izquierda a derecha
  • También existen reglas especiales como la inicialización de variables estáticas y constant initialization, pero solo con la inicialización de objetos comunes las reglas ya son suficientemente complejas
  • En la práctica, es más seguro escribir un constructor explícito para dejar clara la intención de inicialización, en vez de depender de constructores por defecto implícitos y reglas de inicialización

1 comentarios

 
GN⁺ 2024-07-06
Comentarios en Hacker News
  • La explicación de que al inicializar por valor T el resultado es 0 era correcta; al principio se me pasó, pero el autor realmente estaba inicializando x por valor
    En general, las reglas de inicialización de C++ se han vuelto enrevesadas y tienen partes poco intuitivas tras 40 años de expansiones y mantenimiento, pero diría que en un 99.9% de los casos funcionan como uno esperaría
    Creo que una gran mejora sería hacer explícita la inicialización por defecto y que todo lo demás siempre hiciera inicialización por valor. Cuando solo quieras evitar el costo de llenar de 0 un arreglo grande, sería mejor indicarlo explícitamente con una sintaxis como std::array = void;

    • Si inicializas la instancia de verdad, no tendrás problemas, y si quieres llamar a un constructor, entonces define un constructor
      Parece un caso de alguien que quiso pasarse de listo y luego se queja de que es difícil de manejar
  • Todo el enfoque está mal. No metas una referencia const dentro de un struct; si de verdad lo necesitas, lo correcto sería usar std::reference_wrapper
    La respuesta es un poco seca, pero el punto clave es que la conclusión del artículo está completamente equivocada. No uses constructores escritos a mano y sigue la regla de 5/3/0; y si necesitas conservar una referencia const, entonces verifica si estás pasando un temporal rvalue. No es un problema tan aterrador

    • La regla de 5/3/0 trata sobre el destructor, el constructor de movimiento y el constructor de copia. Salvo el último ejemplo, el artículo trata sobre todo de comportamientos peculiares de lo que podríamos llamar constructores estándar
    • Pienso lo mismo. Da mucho la impresión de que el autor fue a buscar algo de qué quejarse ignorando deliberadamente algunos principios y pautas muy básicos
      Incluso alguien que solo haya pasado por un tutorial básico simplemente evita este tipo de escenarios artificiales
    • Nunca he usado std::reference_wrapper, y tampoco lo he visto en varias bases de código C++ en las que he trabajado
      Tal vez se use en algún rincón de código de plantillas profundo y complejo, pero aunque pueda ser cierto, en mi experiencia no es un conocimiento de sentido común
  • El texto original de I Have No Mouth, and I Must Scream (1967) puede leerse aquí: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...

    • Hablé por teléfono con Harlan Ellison una vez. Cuando tenía 17 años, sonó el teléfono de mi casa a las 2 de la mañana, en la época en que uno arrastraba un cable telefónico de 30 pies hasta su cuarto y cerraba la puerta
      La familia de Martin H Greenberg se estaba quedando en mi casa, y él estaba buscando a Martin. Me gustaba la ciencia ficción y había leído varios de sus libros, pero después de oír su nombre casi recuerdo la conversación como algo borroso. Al final desperté a Martin y le pasé la llamada, y después me costó volver a dormirme
  • Me sorprende que no haya comentarios sarcásticos sobre la parte donde unless está incluso en negritas
    Hay sintaxis elegante como T t{v0}; y T t{v0, v1};, pero la inicialización con un solo elemento no funciona de forma tan confiable como cuando hay dos o más elementos
    En un lenguaje que avanza hacia hacer más fácil construir structs con parameter packs y que incluso soporta algo parecido a arreglos de longitud variable inicializables de esta forma, esta diferencia es rara. El tipo, por supuesto, también podría ser una plantilla
    Puedes usar constructores directos o inicializar una tupla o un arreglo suministrando un solo elemento, pero en casos especiales podría llamarse un constructor equivocado. Cuando descubrí esto justo después de que salieran las listas de inicialización de C++11, pensé que era una locura

    • Las listas de inicialización no importan mucho. Fuera de que las usen algunos contenedores estándar, casi nadie las usa, así que esa función rara simplemente puede ignorarse
  • Para ver todavía más rarezas de C++, recomiendo el C++ FQA: https://yosefk.com/c++fqa/
    Ya tiene como 15 años, pero C++ casi nunca elimina funciones o comportamientos viejos, así que al mismo tiempo no envejece

  • El tema del blog es realmente hermoso. Claramente parece inspirado en las computadoras de la era DEC, pero al mismo tiempo se ve limpio y minimalista, lo cual se siente fresco

    • Me gusta cómo las líneas a la derecha del título responden al ancho de la página y al número de líneas
  • Horrible. Es una de las partes aterradoras de C++. Tiene funciones geniales y es un lenguaje en el que trabaja gente brillante, así que da más pena
    Espero que algo como C++ syntax 2 de Herb lo convierta en un lenguaje que gente común como yo también pueda usar

    • Parece una queja exagerada sobre algo que no representa ningún gran problema para cualquiera que haya hecho aunque sea un poco de desarrollo de software
      Los ejemplos del artículo están hechos para tocar deliberadamente casos límite de la función del lenguaje que genera automáticamente, como excepción, funciones miembro especiales que el programador decidió no escribir por su cuenta
      C++ tiene como objetivo de diseño que solo pagues por lo que usas, y la regla de 3 / regla de 5 es conocimiento básico de nivel introductorio en C++. También forma parte de lo básico saber que estas funciones miembro especiales solo las genera el compilador bajo ciertas condiciones, así que si no se cumplen, no se generan automáticamente
      Al final, no me parece tan absurdo saber que debes definir por tu cuenta el constructor que vas a usar. Tampoco debería sorprender que haya que inicializar al crear una instancia. En la práctica, la gente trabaja así todos los días sin hacer tanto escándalo
  • Leer esto marea. Me recordó cuando intentaba entender los constructores de Java y la inicialización de objetos
    Al menos en mi experiencia hasta ahora, la elección de no tener constructores especiales, como en Go y Rust, hace que muchas cosas sean más simples
    Me pregunto si alguien, después de pasarse a un lenguaje sin constructores, realmente los ha extrañado

    • Hay algunos casos algo crípticos, casi mágicos, que se habilitan gracias a los constructores que operan en el lugar. Por ejemplo, en Rust todavía no existe un comportamiento tipo placement new garantizado
      Claro, puedes ir escribiendo los campos uno por uno manualmente con ptr::write sobre MaybeUninit, pero no es ergonómico y la estructura tiene que permitirlo explícitamente
    • No termino de entender la idea de que el compilador no haga nada sea lo que “realmente hace todo más simple”
      Al final eso significa que hay que declarar y definir explícitamente todos los constructores, y C++ también ofrece esa opción desde el principio. La generación automática de funciones miembro especiales es una función añadida para que, en casos muy específicos, la gente que quiere evitar código repetitivo pueda hacerlo
      Si quieres, puedes agregar tus propios constructores y vivir tranquilamente. Quejarse de que las funciones miembro especiales vuelven complejas las cosas simples es parecido a decir que el inglés no es simple porque el diccionario tiene palabras difíciles
    • Los constructores de Java son relativamente sencillos, pero esta parte de C++ sí parece auténtica magia negra
      Según entiendo, ¿los constructores de Rust no son básicamente parecidos a los de Java?
    • La inicialización en cero de Go también a veces obliga a revisar con cuidado las reglas del lenguaje
      https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
  • Me pregunto si existe alguna herramienta de C++ que agregue o muestre todas las acciones implícitas que ocurren detrás de escena
    Por ejemplo, una herramienta que muestre constructores añadidos automáticamente, constructores de copia implícitos y otras sorpresas similares

    • En la práctica, probablemente lo mejor sea combinar Godbolt con cppinsights
  • En el caso de T::T() = default;, uno esperaría que el resultado impreso fuera 0, pero el ejemplo donde termina siendo un valor basura en realidad no es tan extraño
    Enlace relacionado: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
    Porque si se hiciera de otra manera, cualquier usuario de la biblioteca podría cambiar el comportamiento de la biblioteca

    • Que la alternativa no tenga sentido no hace que esta solución automáticamente sí lo tenga. Más bien muestra hasta qué punto el diseño de C++ se ha ido acorralando en demasiados rincones