🔝 Retour au Sommaire
- 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.
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.
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); // OKC'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.
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 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.
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 constructionC'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.
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.
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.
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[] nullptrne 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é.
- 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
= defaultcombiné 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érezWidget w;ouWidget w{};.