Skip to content

Latest commit

 

History

History
315 lines (222 loc) · 12.6 KB

File metadata and controls

315 lines (222 loc) · 12.6 KB

🔝 Retour au Sommaire

6.2.1 — Constructeur par défaut

Chapitre 6 : Classes et Encapsulation


Ce que vous allez apprendre

  • Ce qu'est un constructeur par défaut et quand il est appelé.
  • Quand le compilateur en génère un implicitement — et quand il ne le fait pas.
  • La différence entre un constructeur par défaut vide, implicite, et = default.
  • Les pièges de l'initialisation des types primitifs.
  • Quand et pourquoi écrire un constructeur par défaut explicite.

Définition

Un constructeur par défaut est un constructeur qui peut être appelé sans argument. Cela recouvre deux cas :

class A {  
public:  
    A() {}                             // Cas 1 : aucun paramètre
};

class B {  
public:  
    B(int x = 0, int y = 0) {}        // Cas 2 : tous les paramètres ont une valeur par défaut
};

A a;     // Appelle A()  
B b;     // Appelle B(0, 0)  

Le point important n'est pas l'absence de paramètres dans la déclaration, mais la possibilité d'appeler le constructeur sans fournir d'argument. B(int, int) avec deux valeurs par défaut est bien un constructeur par défaut.


Génération implicite

Comme évoqué en section 6.2, le compilateur génère automatiquement un constructeur par défaut si — et seulement si — vous ne déclarez aucun constructeur :

class ImplicitDefault {  
private:  
    int count_ = 0;
    std::string label_ = "none";
};

ImplicitDefault obj;   // OK — constructeur par défaut généré implicitement
                       // count_ = 0, label_ = "none" (default member initializers)

Le constructeur implicite est équivalent à un constructeur vide : il n'exécute aucun code, mais les default member initializers (section 6.1) sont appliqués, et les membres de type classe (comme std::string) sont construits via leur propre constructeur par défaut.

Dès que vous déclarez un constructeur quelconque, la génération implicite disparaît :

class NoImplicitDefault {  
public:  
    NoImplicitDefault(int value) : value_(value) {}
    // Le compilateur ne génère plus NoImplicitDefault()
private:
    int value_;
};

// NoImplicitDefault obj;     // ERREUR : pas de constructeur par défaut
NoImplicitDefault obj(42);    // OK

C'est un comportement voulu : si vous avez défini un constructeur exigeant un int, le compilateur en déduit que la classe n'a pas de sens sans cette valeur. Il ne va pas inventer un état par défaut à votre place.


= default : rétablir le constructeur par défaut

Si vous voulez à la fois un constructeur paramétré et un constructeur par défaut, vous avez deux options. La première consiste à l'écrire manuellement :

class Config {  
public:  
    Config() : timeout_(30), retries_(3) {}
    Config(int timeout, int retries) : timeout_(timeout), retries_(retries) {}
private:
    int timeout_;
    int retries_;
};

La seconde, plus concise et recommandée quand le comportement par défaut suffit, utilise = default (C++11) :

class Config {  
public:  
    Config() = default;
    Config(int timeout, int retries) : timeout_(timeout), retries_(retries) {}
private:
    int timeout_ = 30;       // default member initializer
    int retries_ = 3;        // default member initializer
};

= default demande explicitement au compilateur de générer le constructeur par défaut qu'il aurait généré s'il n'y avait aucun autre constructeur. Les default member initializers prennent alors toute leur importance : c'est eux qui déterminent les valeurs initiales.

Pourquoi préférer = default à un corps vide ?

Écrire Config() {} et Config() = default; semblent équivalents, mais = default a deux avantages. Premièrement, il signale votre intention au lecteur : "je veux le comportement par défaut du compilateur, sans logique supplémentaire." Deuxièmement, le compilateur peut traiter un constructeur = default comme trivial, ce qui autorise certaines optimisations (placement en mémoire, memcpy de structures, std::is_trivially_constructible). Un constructeur avec un corps vide, même s'il ne fait rien, n'est pas considéré comme trivial.


Le piège des types primitifs non initialisés

Le constructeur par défaut implicite (ou = default) n'initialise pas les types primitifs qui n'ont pas de default member initializer. C'est l'un des pièges les plus fréquents en C++ :

class Dangerous {  
public:  
    Dangerous() = default;
    int value() const { return value_; }
private:
    int value_;            // Pas de default member initializer !
    double ratio_;         // Idem
    bool active_;          // Idem
};

Dangerous d;  
std::cout << d.value();    // Comportement indéfini — value_ contient des données aléatoires  

Les types comme int, double, bool, et les pointeurs bruts ne possèdent pas de constructeur — ils sont hérités du C. Le constructeur par défaut implicite n'y touche pas. En pratique, la valeur sera "ce qui traînait en mémoire à cet emplacement", c'est-à-dire n'importe quoi.

Les types de la bibliothèque standard (std::string, std::vector, std::unique_ptr, etc.) ne sont pas concernés : ils ont leurs propres constructeurs par défaut qui les placent dans un état vide et valide.

La solution est systématique — utilisez les default member initializers pour tous les membres primitifs :

class Safe {  
public:  
    Safe() = default;
private:
    int value_ = 0;
    double ratio_ = 0.0;
    bool active_ = false;
    int* ptr_ = nullptr;
};

C'est la recommandation C.45 des C++ Core Guidelines : "Don't define a default constructor that only initializes data members; use default member initializers instead." En d'autres termes, si votre constructeur par défaut ne fait que donner des valeurs aux membres, préférez les default member initializers combinés avec = default.


Quand un constructeur par défaut n'a pas de sens

Certaines classes ne devraient jamais être construites sans argument. Un descripteur de fichier sans chemin, une connexion réseau sans adresse, un thread sans fonction à exécuter — ces objets n'ont pas d'état "vide" significatif.

Dans ce cas, ne fournissez pas de constructeur par défaut. C'est le comportement naturel si vous déclarez uniquement un constructeur paramétré :

class FileHandle {  
public:  
    explicit FileHandle(const std::string& path)
        : fd_(::open(path.c_str(), O_RDONLY)) {
        if (fd_ < 0) {
            throw std::runtime_error("Cannot open: " + path);
        }
    }

    ~FileHandle() {
        if (fd_ >= 0) ::close(fd_);
    }

    // Pas de constructeur par défaut — un FileHandle sans fichier n'a pas de sens

private:
    int fd_;
};

// FileHandle f;                       // ERREUR — pas de constructeur par défaut
FileHandle f("/etc/hostname");         // OK — le fichier est ouvert dès la construction

C'est une décision de conception, pas un oubli. Le compilateur devient votre allié : il empêche quiconque de créer un FileHandle dans un état invalide.

Si vous voulez rendre cette intention encore plus visible, vous pouvez supprimer explicitement le constructeur par défaut :

class FileHandle {  
public:  
    FileHandle() = delete;                           // Intention documentée
    explicit FileHandle(const std::string& path);
    // ...
};

= delete n'est pas strictement nécessaire ici (la déclaration du constructeur paramétré supprime déjà la génération implicite), mais il rend l'intention explicite dans l'interface. C'est une question de style — certaines équipes le recommandent systématiquement, d'autres le considèrent redondant.


Contextes d'appel du constructeur par défaut

Le constructeur par défaut est invoqué dans plus de situations qu'on ne le pense au premier abord. Voici les contextes principaux :

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

// 1. Déclaration sans argument
Widget w1;  
Widget w2{};  

// 2. Allocation dynamique sans argument
Widget* pw = new Widget;  
Widget* pw2 = new Widget();  

// 3. Élément d'un tableau
Widget arr[3];           // Widget() appelé 3 fois

// 4. Conteneur standard
std::vector<Widget> v(5);   // Widget() appelé 5 fois

// 5. Membre d'une autre classe sans initialisation explicite
class Panel {
    Widget child_;         // Widget() appelé quand Panel est construit
};

Le cas 5 est le plus subtil. Quand une classe possède un membre de type Widget sans default member initializer ni mention dans la liste d'initialisation du constructeur, le compilateur appelle le constructeur par défaut de Widget. Si Widget n'en a pas, la compilation échoue — même si le problème ne se trouve pas dans Widget mais dans Panel.


Attention à la syntaxe : le "most vexing parse"

Un piège historique du C++ mérite d'être mentionné. La syntaxe suivante ne fait pas ce que l'on croit :

Widget w();    // Ce n'est PAS une construction par défaut !

Le compilateur interprète cette ligne comme la déclaration d'une fonction w qui ne prend aucun argument et retourne un Widget. C'est ce qu'on appelle le most vexing parse — l'analyse syntaxique la plus frustrante du C++.

Les solutions :

Widget w1;       // Construction par défaut — forme classique  
Widget w2{};     // Construction par défaut — forme C++11, sans ambiguïté  

La syntaxe à accolades {} élimine toute ambiguïté et constitue la forme recommandée en C++ moderne.


Fil conducteur : constructeur par défaut de DynArray

Notre classe DynArray a déjà un constructeur paramétré (DynArray(std::size_t size)). Mais il est utile de pouvoir créer un tableau vide et le remplir plus tard. Ajoutons un constructeur par défaut :

dynarray.h — ajout du constructeur par défaut :

class DynArray {  
public:  
    DynArray() = default;                     // Tableau vide par défaut
    explicit DynArray(std::size_t size);       // Tableau de taille donnée

    ~DynArray();

    std::size_t size() const { return size_; }
    bool empty() const { return size_ == 0; }

    int& operator[](std::size_t index);
    const int& operator[](std::size_t index) const;

private:
    int* data_ = nullptr;      // default member initializer → pas de fuite
    std::size_t size_ = 0;     // default member initializer → état cohérent
};

Ici, = default suffit parce que les default member initializers (nullptr et 0) placent l'objet dans un état valide et cohérent : un tableau vide, sans mémoire allouée. Le destructeur (delete[] nullptr) est une opération sûre en C++ — delete sur nullptr est un no-op garanti par le standard.

Vérifions que tout fonctionne ensemble :

DynArray empty;                   // data_ = nullptr, size_ = 0  
std::cout << empty.size() << "\n";  // 0  
std::cout << empty.empty() << "\n"; // 1 (true)  

DynArray filled(5);               // data_ = new int[5]{}, size_ = 5  
std::cout << filled.size() << "\n"; // 5  
filled[0] = 42;  

L'objet empty est dans un état parfaitement défini : un tableau vide. L'objet filled contient 5 entiers initialisés à zéro. Les deux respectent l'invariant de la classe (si size_ == 0, alors data_ == nullptr ; si size_ > 0, alors data_ pointe vers un bloc valide de size_ entiers).

💡 Notez que nous n'avons pas besoin de modifier le destructeur. delete[] nullptr ne fait rien — c'est garanti par le standard (§7.6.2.9). Le destructeur existant (delete[] data_) gère correctement les deux cas : tableau vide et tableau alloué.


Points clés à retenir

  • Un constructeur par défaut est un constructeur appelable sans argument — soit parce qu'il n'a aucun paramètre, soit parce que tous ont des valeurs par défaut.
  • Le compilateur en génère un implicitement uniquement si vous ne déclarez aucun constructeur.
  • Préférez = default combiné avec des default member initializers plutôt qu'un constructeur à corps vide. C'est plus clair, potentiellement plus performant, et conforme aux Core Guidelines.
  • Les types primitifs (int, double, bool, pointeurs) ne sont pas initialisés par le constructeur implicite. Utilisez systématiquement des default member initializers pour eux.
  • Si votre classe n'a pas d'état "vide" significatif, ne fournissez pas de constructeur par défaut. Le compilateur empêchera la création d'objets invalides.
  • Évitez Widget w(); (déclaration de fonction). Préférez Widget w; ou Widget w{};.

⏭️ Constructeur paramétré