Fonctions supprimées et fonctions par défaut

Une classe C++ est en mesure de représenter une entité concrète. Par exemple, un point 2D immuable :

#include <type_traits>
template <class T>
   struct Point2D final {
      static_assert(std::is_integral_v<T>); // pour operator==
      using value_type = T;
      value_type x, y;
      constexpr Point2D() : x{}, y{} {
      }
      constexpr Point2D(const value_type &x, const value_type &y) : x{ x }, y{ y } {
      }
      constexpr bool operator==(const Point2D &pt) const {
         return x == pt.x && y == pt.y;
      }
      constexpr bool operator!=(const Point2D &pt) const {
         return !(*this == pt);
      }
      // ... etc.
   };

Cette classe est concrète au sens où elle n'est pas destinée à servir de devanture pour des comportements polymorphiques. La mention final en fait explicitement état, tout comme l'absence de méthodes virtuelles le fait de manière plus implicite. Cette classe est écrite en présumant que le type T sera entier (comme en fait foi l'assertion statique qu'on y retrouve), et il est conséquemment raisonnable d'y supposer que tous les états implémentent la Sainte-Trinité. Conséquemment, nous n'avons pas écrit les opérations de la Sainte-Trinité pour Point2D : ce que le compilateur générera pour nous sera supérieur en qualité à ce que nous aurions pu écrire nous-mêmes.

En pratique, il arrive que nous voulions « jouer » avec certaines fonctions clés :

Pour ces cas d'espèces, entre autres, il est possible d'utiliser les qualifications = default (pour une fonction dont l'implémentation par défaut du compilateur doit s'appliquer) ou = delete (pour une fonction qui doit être supprimée, carrément).

D'ailleurs, on peut déjà simplifier la classe Point2D ci-dessus en profitant de l'initialisation implicite des attributs :

#include <type_traits>
template <class T>
   struct Point2D final {
      static_assert(std::is_integral_v<T>); // pour operator==
      using value_type = T;
      value_type x {}, y {}; // ICI : implicitement initialisées au zéro du type
      Point2D() = default; // ICI : le compilateur fera implicitement cette initialisation
      constexpr Point2D(const value_type &x, const value_type &y) : x{ x }, y{ y } {
      }
      constexpr bool operator==(const Point2D &pt) const {
         return x == pt.x && y == pt.y;
      }
      //
      // si vous compilez pour C++20, pas besoin d'expliciter operator!= si vous
      // avez implémenté operator== car le compilateur le synthétisera pour vous
      //
      constexpr bool operator!=(const Point2D &pt) const {
         return !(*this == pt);
      }
      // ... etc.
   };

Applications de la qualification = default

Imaginons une classe Dessinable dans laquelle il existe au moins une méthode virtuelle dessiner(), réalisée ici par l'idiome NVI, comme il se doit. Étant polymorphique, nous souhaitons que cette classe expose un destructeur virtuel; nous souhaitons aussi avoir la meilleure implémentation possible pour cette fonction. Avant C++ 11, nous aurions écrit quelque chose comme ce qui suit :

class Dessinable {
   // ...
public:
   void dessiner() const {
      dessiner_impl(); // idiome NVI
   }
protected:
   virtual void dessiner_impl() const = 0;
public:
   virtual ~Dessinable() { // <-- Ok mais pas optimal
   }
};

Dans cette implémentation, écrire une méthode « bidon » réduit la qualité du code généré, car en présence d'une implémentation – même vide – suppléée par le code source, le compilateur doit présumer d'une intention et ne peut aller à fond dans l'optimisation du code généré. Depuis C++ 11, il est préférable d'écrire ceci :

class Dessinable {
   // ...
public:
   void dessiner() const {
      dessiner_impl(); // idiome NVI
   }
protected:
   virtual void dessiner_impl() const = 0;
public:
   virtual ~Dessinable() = default; // <-- mieux!
};

Ceci clarifie l'intention des programmeuses et des programmeurs en explicitant le souhait de voir le compilateur générer le code par défaut.

La spécification = default peut s'appliquer à la déclaration comme à la définition d'une fonction. Pour cette raison, elle est particulièrement utile dans la mise en place de l'idiome pImpl, comme le montre l'exemple suivant :

Fichier Semaphore.h
#ifndef SEMAPHORE_H
#define SEMAPHORE_H
#include <memory>
class Semaphore {
   class Impl;
   std::unique_ptr<Impl> p; // incopiable
public:
   Semaphore(int);
   Semaphore(Semaphore &&);
   Semaphore& operator=(Semaphore &&);
   ~Semaphore();
   void obtenir() const;
   void relacher() const;
};
#endif

En vertu de l'idiome pImpl, le fichier d'en-tête Semaphore.h est pleinement portable. Il est important d'y déclarer un destructeur, sinon (dû à la Sainte-Trinité) le compilateur en générera un, et ce destructeur implicite appellera dans le .h le destructeur de p, qui détruira le pointé, un Semaphore::Impl... Or, à ce stade, Semaphore::Impl est un type incomplet, et on ne sait même pas s'il expose un destructeur accessible à unique_ptr<Semaphore::Impl>!

Fichier Semaphore.cpp
#include "Semaphore.h"
#include <windows.h> // par exemple
class Semaphore::Impl {
   HANDLE h;
public:
   Impl(int n) : h{CreateSemaphore(nullptr, 0, n, nullptr)} {
   }
   ~Impl() {
      CloseHandle(h);
   }
   void obtenir() const {
      WaitForSingleObject(h, INFINITE);
   }
   void relacher() const {
      ReleaseSemaphore(h);
   }
};
Semaphore::Semaphore(int n) : p{ new Impl{n} } {
}
Semaphore::~Semaphore() = default; // <-- ICI
void Semaphore::obtenir() const {
   p->obtenir();
}
void Semaphore::relacher() const {
   p->relacher();
}

En plaçant la spécification = default dans le .cpp, on force le compilateur à générer le code par défaut mais seulement à cet endroit, là où il est possible pour lui de le faire. Évidemment, la spécification = default n'a de sens que pour les fonctions qui ont effectivement un comportement par défaut (Sainte-Trinité, constructeur par défaut).

Applications de la qualification = delete

De son côté, la spécification = delete permet de supprimer une fonction qui, autrement, aurait été générée. Par exemple :

class X;
template <class T>
   void f(T); // s'applique à presque tout type T...
template <>
   void f(X) = delete; // ... mais pas à X

Les cas les plus communs d'utilisation de cette spécification servent à supprimer les opérations de la Sainte-Trinité, surtout les opérations de copie. Comparez par exemple ces deux versions de l'idiome de classe Incopiable, l'une conçue pour C++ 03 et l'autre pour C++ 11 :

Version C++ 03Version C++ 11
class Incopiable {
   Incopiable(const Incopiable&);
   Incopiable& operator=(const Incopiable&);
protected:
   Incopiable() noexcept {}
   ~Incopiable() {}
};
struct Incopiable {
   Incopiable(const Incopiable&) = delete;
   Incopiable& operator=(const Incopiable&) = delete;
protected:
   Incopiable() = default;
   ~Incopiable() = default;
};

La plus grande qualité de cette nouvelle notation n'est pas tant qu'elle est plus concise et plus explicite que la précédente; en fait, sa plus grande qualité est que dans le cas d'une opération explicitement supprimée, solliciter l'opération devient une erreur de compilation, qu'il est possible de diagnostiquer avec aisance (le compilateur rapportant un fichier et un numéro de ligne pour le point où l'erreur est survenue) plutôt qu'une erreur, plus vague, d'édition des liens. Scott Meyers, dans Effective Modern C++, recommande de qualifier les méthodes supprimées public pour que le compilateur génère de meilleurs diagnostics (on préfère savoir qu'elle est supprimée, pas qu'elle est inaccessible).

Il existe toutefois plusieurs autres applications novatrices de cette qualification, par exemple celle-ci, suggérée par Gaetano Mendola (voir http://cpp-today.blogspot.it/2014/02/the-under-evaluated-delete-specifier_16.html pour l'article original) en 2014 :

// ... inclusions et using ...
class Nom {
   const string &val; // <-- ICI, discutable...
public:
   Nom(const string &val) : val{ val } {
   }
   void projeter(ostream &os) const {
      os << val;
   }
   friend ostream& operator<<(ostream &os, const Nom &nom) {
      nom.projeter(os);
      return os;
   }
};
int main() {
  Nom nom{ "J'aime mon prof" }; // oups!
  cout << nom << endl; // boum!
}

Le problème ici est que le constructeur paramétrique de Nom accepte un const string& et que le passage, dans main(), d'un const char* génère une temporaire. Malheureusement, Nom ne copie pas cette temporaire, et prend plutôt une référence sur elle, ce qui a pour conséquence que l'appel à projeter(), qui suit, mène à un comportement indéfini (ce à quoi réfère nom.val n'existe plus).

Une solution simple est de rendre illégal le recours, à la construction de Nom, d'une rvalue. Ici, la std::string générée par "J'aime mon prof" est une entité jetable, n'ayant pas de nom, alors il est possible d'éviter le problème en rendant Nom incapable d'accepter un objet jetable à la construction :

// ... inclusions et using ...
class Nom {
   const string &val;
public:
   Nom(const string &val) : val{ val } {
   }
   Nom(const string &&val) = delete; // <-- ICI
   void projeter(ostream &os) const {
      os << val;
   }
   friend ostream& operator<<(ostream &os, const Nom &nom) {
      nom.projeter(os);
      return os;
   }
};
int main() {
  // Nom nom{ "J'aime mon prof" }; // illégal
  string s = "J'aime mon prof";
  Nom nom{ s }; // Ok
  cout << nom << endl; // Ok
}

Voilà.

Lectures complémentaires

Pour des explications supplémentaires :


Valid XHTML 1.0 Transitional

CSS Valide !