1 points par GN⁺ 2024-07-06 | 1 commentaires | Partager sur WhatsApp
  • En C++, l’initialisation d’objet avec une syntaxe pourtant proche comme T t;, T t{};, T t{...} se divise en default/value/list/aggregate initialization, ce qui peut aboutir à des états membres réellement différents
  • Le fait de mettre le constructeur par défaut en = default à tel ou tel endroit change l’application ou non de la zero-initialization ; s’il est défini hors de la classe, les membres peuvent rester avec des valeurs non initialisées
  • Même un type avec un membre const int, qui semble devoir faire supprimer le constructeur par défaut, peut tout de même être initialisé à 0 via A a{}; s’il est un aggregate, grâce à l’aggregate initialization
  • En C++20, l’aggregate initialization avec parenthèses est plus permissive que l’initialisation avec accolades, ce qui introduit des différences comme les narrowing conversions et les références pendantes
  • En pratique, mieux vaut exprimer explicitement l’intention d’initialisation avec un constructeur écrit à la main plutôt que de dépendre de la combinaison entre constructeurs implicites et règles d’initialisation

Là où la syntaxe d’initialisation C++ bifurque

  • T t; effectue une default-initialization
    • si T est un type classe et possède un constructeur par défaut, ce constructeur est exécuté
    • si T est un tableau, chaque élément est default-initialized
    • sinon, il ne se passe rien
  • T t{}; ressemble à une value-initialization, mais à cause de la syntaxe avec accolades, c’est d’abord traité comme une list-initialization
  • Même dans un exemple simple, la différence apparaît immédiatement
    • int x{}; est value-initialized, donc initialisé à 0
    • int y; est default-initialized, donc sa valeur n’est pas initialisée
    • Pair p; et Pair q{};, avec un constructeur par défaut, appellent ce constructeur
    • SimplePair r;, qui n’a que des membres, est default-initialized et ses membres peuvent ne pas être initialisés

L’effet de l’emplacement de déclaration du constructeur par défaut

  • Si l’utilisateur ne déclare aucun constructeur, le compilateur déclare un constructeur par défaut implicitly-declared
  • Utiliser T() = default; dans la première déclaration à l’intérieur de la classe est, du point de vue de la norme, traité presque comme un constructeur implicitement déclaré
  • Si le constructeur par défaut implicitement déclaré ou explicitement defaulted n’est pas supprimé, le compilateur fournit un implicitly-defined default constructor
    • en pratique, cela doit être équivalent à T() {} avec un corps vide et une liste d’initialisation vide
  • Dans le code suivant, t.x vaut 0
struct T {
    int x;
    T() = default;
};
T t{};
std::cout << t.x << std::endl;
  • En effet, t est value-initialized, et comme le constructeur par défaut de T n’est pas user-provided, une zero-initialization a lieu d’abord, puis le constructeur par défaut est appelé

= default hors de la classe et constructeur user-provided

  • Le même = default, s’il est défini hors de la classe, devient un constructeur user-provided
struct T {
    int x;
    T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
  • Ici, T::T() = default; n’est pas defaulted dans la première déclaration, mais défini comme defaulted hors de la classe
  • Les règles de value-initialization disent que s’il existe un constructeur par défaut user-provided ou deleted, alors une default-initialization est effectuée
  • Dans cet exemple, le constructeur par défaut est donc appelé sans zero-initialization préalable, et comme il ne fait rien, t.x contient une valeur indéterminée

Les cas où le constructeur par défaut est supprimé

  • Si le compilateur ne peut pas produire un constructeur par défaut raisonnable, le constructeur par défaut implicitement déclaré peut être défini comme deleted
  • Parmi les conditions classiques :
    • présence d’un membre référence non statique
    • présence d’un membre non statique ou d’une base non abstraite qui ne peut pas être correctement default-construct ou destruct
    • présence d’un membre non statique const sans initialiseur par défaut, si ce membre n’est pas const-default-constructible
  • Si l’utilisateur fournit lui-même un constructeur, le compilateur ne cherche pas à définir un constructeur par défaut implicite et n’en crée pas non plus de version deleted
  • Même avec ces contraintes, les règles d’initialisation de C++ restent globalement très permissives

Quand l’aggregate initialization entre en jeu

  • struct A { const int x; }; A a{}; semble devoir échouer à la compilation parce que le constructeur par défaut serait supprimé, mais en réalité le code compile et a.x vaut 0
  • Le point clé est que A est un aggregate
  • Un aggregate est soit un tableau, soit une classe qui satisfait les conditions suivantes
    • aucun constructeur user-declared ou hérité
    • aucun membre de données direct non statique private ou protected
    • aucune classe de base directe private ou protected
    • aucune fonction virtual ni classe de base virtuelle
  • Quand une list-initialization s’applique à un aggregate, alors sauf exceptions particulières, on utilise une aggregate initialization
    • chaque élément de la liste initialise à son tour chaque élément de l’aggregate, dans l’ordre
    • si la liste contient moins d’éléments que nécessaire, les éléments restants sont initialisés via leur initialiseur par défaut s’il existe
    • s’il n’y a pas d’initialiseur par défaut et qu’il ne s’agit pas d’une référence, l’élément est copy-initialized à partir d’une liste vide
  • Ainsi, A a{}; ne fait pas appel à un constructeur : a.x est copy-list-initialized à partir d’une liste vide, ce qui passe ensuite par une value-initialization puis une zero-initialization

Les règles supplémentaires de l’initialisation avec accolades

  • La syntaxe avec accolades, comme T t{...} et T t = {...}, relève en général de la list-initialization
    • T t{...} est une direct-list-initialization
    • T t = {...} est une copy-list-initialization
  • Pour un type classe qui n’est pas un aggregate, si la liste d’initialisation est vide et qu’un constructeur par défaut existe, une value-initialization est effectuée
  • Dans les autres cas, on applique la résolution de surcharge habituelle sur les constructeurs, avec priorité aux surcharges de constructeur std::initializer_list
  • L’initialisation avec accolades n’autorise pas les narrowing conversions lors d’une initialisation à un seul élément
struct A {
    const int x;
};

A b{4};      // possible
A c{4.0f};   // erreur de narrowing conversion

L’initialisation avec parenthèses est plus permissive

  • Une initialisation avec parenthèses comme T t(a, b, c); déclenche une direct-non-list-initialization, proche de la direct-list-initialization, mais avec des règles différentes
  • T t(); n’instancie pas un objet, mais est interprété comme une déclaration de fonction sans argument retournant T : c’est le problème du most vexing parse
  • Depuis C++20, les aggregates peuvent eux aussi être initialisés avec des parenthèses
  • Cette aggregate initialization avec parenthèses se comporte différemment de la version avec accolades
    • elle autorise les narrowing conversions
    • les éléments restants sont directement value-initialized, et non initialisés via une liste vide
    • elle ne prolonge pas la durée de vie des objets temporaires liés à des références
struct T {
    const int& r;
};
T t(42);
  • Dans ce code, t.r devient une dangling reference, et sa lecture provoque un undefined behaviour

std::initializer_list, constructeur de copie et elision

  • L’initialisation avec parenthèses peut appeler le constructeur de copie de T même lorsqu’il existe une surcharge de constructeur std::initializer_list<T>
  • À l’inverse, avec les accolades, le constructeur std::initializer_list peut être prioritaire
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, pas de copy
  • Si T q(T{}); n’effectue pas de copie, c’est parce qu’en direct-initialization comme en copy-initialization, quand l’initialiseur est un prvalue de type T, l’objet est initialisé directement à partir de cette expression
  • Ce comportement est généralement connu sous le nom de copy elision, même si la norme ne l’appelle pas explicitement ainsi
  • La norme décrit moins clairement si la même elision est possible avec la list-initialization ; le sujet est lié à la CWG issue 2311
    • GCC et Clang effectuent l’elision dans la plupart des cas
    • s’il existe une surcharge de constructeur std::initializer_list<T>, GCC utilise ce constructeur au lieu de faire l’elision

Ordre d’évaluation et conclusion pratique

  • L’ordre d’évaluation des éléments dans une liste d’initialisation avec parenthèses n’est pas garanti
  • Une liste d’initialisation avec accolades évalue strictement ses éléments de gauche à droite
  • Il existe aussi des règles spéciales comme l’initialisation des variables statiques ou la constant initialization, mais même l’initialisation d’objets ordinaires est déjà suffisamment complexe
  • En pratique, il est plus sûr d’écrire un constructeur explicite pour rendre l’intention d’initialisation évidente, plutôt que de s’en remettre aux constructeurs implicites et aux règles d’initialisation

1 commentaires

 
GN⁺ 2024-07-06
Avis sur Hacker News
  • L’explication selon laquelle l’initialisation par valeur de T donne 0 était correcte ; je ne l’avais pas vu au début, mais l’auteur initialisait bien x par valeur
    Dans l’ensemble, les règles d’initialisation de C++ sont devenues obscures et parfois peu intuitives après 40 ans d’extensions et de maintenance, mais dans 99,9 % des cas elles fonctionnent à peu près comme on s’y attend
    Je pense qu’une grande amélioration serait de rendre l’initialisation par défaut explicite, et de faire en sorte que tout le reste soit toujours une initialisation par valeur. Quand on veut seulement éviter le coût de remplir un grand tableau avec des zéros, il vaudrait mieux l’indiquer explicitement avec une syntaxe comme std::array = void;

    • Si on initialise réellement l’instance, on n’a pas de problème, et si on veut appeler un constructeur, il suffit de définir un constructeur
      Ça ressemble à un cas où l’on essaie d’être trop malin, puis où l’on se plaint que ce soit difficile à gérer
  • Toute l’approche est erronée. Il ne faut pas mettre de référence const dans une structure ; si c’est vraiment nécessaire, mieux vaut utiliser std::reference_wrapper
    La réponse est un peu sèche, mais l’essentiel est que la conclusion de l’article est complètement fausse. Il ne faut pas éviter d’écrire des constructeurs soi-même ; il faut suivre la règle des 5/3/0, et si l’on doit conserver une référence const, il faut vérifier qu’on ne passe pas un temporaire rvalue. Ce n’est pas un problème si effrayant

    • La règle des 5/3/0 concerne le destructeur, le constructeur de déplacement et le constructeur de copie. À part le dernier exemple, l’article traite surtout du comportement étrange de ce qu’on pourrait appeler des constructeurs standards
    • Je suis du même avis. On a vraiment l’impression que l’auteur a délibérément ignoré quelques principes et recommandations très basiques pour se fabriquer un motif de plainte
      Même quelqu’un qui n’a suivi qu’un tutoriel de base évite simplement ce genre de scénario artificiel
    • Je n’ai jamais utilisé std::reference_wrapper, et je ne l’ai jamais vu non plus dans les différentes codebases C++ sur lesquelles j’ai travaillé
      C’est peut-être utilisé quelque part dans du code template profond et complexe, donc c’est peut-être vrai, mais d’après mon expérience ce n’est pas un savoir de bon sens
  • Le texte original de I Have No Mouth, and I Must Scream (1967) est disponible ici : https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...

    • J’ai parlé une fois au téléphone avec Harlan Ellison. Quand j’avais 17 ans, le téléphone a sonné chez moi à 2 heures du matin ; c’était à l’époque où on tirait un fil de téléphone de 30 pieds jusque dans sa chambre pour fermer la porte
      La famille de Martin H Greenberg logeait chez nous, et il cherchait Martin. J’aimais la science-fiction et j’avais lu plusieurs de ses livres, mais après avoir entendu son nom, mes souvenirs de la conversation deviennent assez flous. J’ai fini par réveiller Martin et lui passer l’appel, puis j’ai eu du mal à me rendormir
  • Je suis surpris qu’il n’y ait pas de commentaire sarcastique sur le passage où unless est même mis en gras
    Il existe une syntaxe élégante comme T t{v0}; et T t{v0, v1};, mais l’initialisation avec un seul élément ne fonctionne pas aussi fiablement que dans le cas de deux éléments ou plus
    Dans un langage qui évolue vers une création plus facile de structures avec des packs de paramètres, et qui prend aussi en charge des sortes de tableaux à longueur variable initialisables de cette façon, cette différence est étrange. Le type peut bien sûr aussi être un template
    On peut écrire un constructeur soi-même, ou initialiser un tuple ou un tableau en ne fournissant qu’un seul élément, mais dans certains cas particuliers, c’est un constructeur inattendu qui peut être appelé. Quand j’ai découvert ça au moment de l’arrivée des listes d’initialisation en C++11, j’ai trouvé ça complètement fou

    • Les listes d’initialisation n’ont pas beaucoup d’importance. À part quelques conteneurs standards qui les utilisent, presque personne ne s’en sert, donc on peut simplement ignorer cette fonctionnalité bizarre
  • Pour voir encore plus d’étrangetés de C++, je recommande la C++ FQA : https://yosefk.com/c++fqa/
    Ça a maintenant environ 15 ans, mais comme C++ supprime très rarement ses anciennes fonctionnalités ou ses vieux comportements, ça ne vieillit en même temps presque pas

  • Le thème du blog est vraiment magnifique. Il semble clairement inspiré des ordinateurs de l’ère DEC, tout en restant propre, minimaliste et rafraîchissant

    • J’aime la façon dont les lignes à droite du titre réagissent à la largeur de la page et au nombre de lignes
  • Horrible. C’est l’un des aspects effrayants de C++. C’est d’autant plus dommage que le langage a aussi des fonctionnalités élégantes et que des gens brillants travaillent dessus
    J’espère que quelque chose comme le C++ syntax 2 de Herb en fera un langage que des gens ordinaires comme moi pourront utiliser

    • On dirait qu’on se plaint de manière excessive d’un point qui ne pose guère de problème à quiconque a déjà fait un peu de développement logiciel
      Les exemples de l’article sont conçus pour aller volontairement chercher des cas limites dans la fonctionnalité par laquelle le langage génère automatiquement, à titre exceptionnel, des fonctions membres spéciales que le programmeur a choisi de ne pas écrire lui-même
      C++ a pour objectif de conception de ne faire payer que ce qu’on utilise, et la règle des 3 / règle des 5 fait partie des connaissances de base de niveau débutant en C++. Le fait que ces fonctions membres spéciales ne soient générées par le compilateur que dans certaines conditions, et donc pas automatiquement si ces conditions ne sont pas réunies, relève aussi des bases
      Au final, je ne vois pas en quoi savoir qu’il faut configurer soi-même le constructeur à utiliser serait si absurde. Le fait qu’il faille initialiser au moment de créer une instance n’a rien de surprenant non plus. Dans la pratique, les gens font réellement leur travail de cette façon sans en faire toute une histoire
  • Ça me donne le vertige rien que de lire ça. Ça me rappelle l’époque où j’essayais de comprendre les constructeurs Java et l’initialisation des objets
    Au moins, d’après mon expérience jusqu’ici, le choix de ne pas avoir de constructeurs spéciaux comme en Go et en Rust simplifie beaucoup de choses
    Je me demande s’il y a des gens qui, après être passés à un langage sans constructeur, ont regretté les constructeurs

    • Il existe quelques cas un peu obscurs, presque magiques, que rendent possibles les constructeurs qui opèrent en place. Par exemple, Rust n’a toujours pas d’équivalent garanti à placement new
      Bien sûr, on peut remplir manuellement les champs un par un avec ptr::write dans MaybeUninit, mais ce n’est pas ergonomique, et la struct doit l’autoriser explicitement
    • Je ne vois pas bien en quoi le fait que le compilateur ne fasse rien « simplifie vraiment » les choses
      Au final, cela veut dire qu’il faut déclarer et définir explicitement tous les constructeurs, et C++ a aussi offert cette possibilité dès le départ. La génération automatique des fonctions membres spéciales est une fonctionnalité ajoutée pour ne fonctionner que dans des cas très précis, pour ceux qui veulent éviter le code répétitif
      Si on veut, on peut ajouter ses propres constructeurs et vivre très normalement. Se plaindre que les fonctions membres spéciales compliquent les choses simples, c’est un peu comme dire que l’anglais n’est pas simple parce qu’il y a des mots difficiles dans le dictionnaire
    • Les constructeurs Java sont plutôt simples, alors que ce côté C++ relève vraiment de la magie noire
      Si j’ai bien compris, les constructeurs en Rust ne ressemblent-ils pas essentiellement à ceux de Java ?
    • La zero initialization de Go aussi oblige parfois à examiner les règles du langage
      https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
  • Je me demande s’il existe un outil C++ qui ajoute ou montre tous les comportements implicites qui se produisent en coulisses
    Par exemple, un outil qui montrerait les constructeurs ajoutés automatiquement, les constructeurs de copie implicites et les autres surprises du genre

    • En pratique, la meilleure option est sans doute de combiner Godbolt et cppinsights
  • Dans le cas de T::T() = default;, on s’attendrait à ce que la sortie soit 0, mais l’exemple où on obtient une valeur indéterminée n’est pas si étrange en réalité
    Lien connexe : https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
    Sinon, n’importe quel utilisateur de la bibliothèque pourrait changer son comportement

    • Ce n’est pas parce que l’alternative n’a pas de sens que cette solution en a automatiquement. Au contraire, cela montre à quel point la conception de C++ s’est elle-même acculée dans de nombreux recoins