Outils pour utilisateurs

Outils du site


lang:cpp:classes

Sans héritage

Multiples constructeurs

Il est possible d'appeler un constructeur depuis un autre constructeur. C'est pratique si on souhaite avoir deux constructeurs très proche.

Bien qu'on soit dans le paragraphe sans héritage, j'ai mis un cas avec héritage pour montrer qu'on a pas besoin d'appeler le constructeur du parent si on appelle un autre constructeur.

class A{};
class B : public A
{
 public:
  // Call constructor B(int) from constructor B().
  // No need (forbidden) to call A.
  B() : B(5){}
  // Avoid default function argument
  B(int a/* = 5*/):A(), a_(a){}
 
 private:
  int a_;
};

Version "Rule of Zero"

Cette règle s'applique lorsque la classe ne gère pas de ressources.

Il est aussi conseillé de réduire son utilisation aux classes n'étant pas polymorphique pour éviter les problèmes de slicing.

class Class
{
 public:
  // Constructor.
  Class() = default;
  // Pas de destructeur. Pas de copy/move contructor/assignment operator
}

Le post originel (?) sur la règle du zéro : Rule of Zero Archive du 10/03/2020 le 20/09/2012

If the definition of a class X does not explicitly declare a move constructor, one will be implicitly declared as defaulted if and only if

  • X does not have a user-declared copy constructor,
  • X does not have a user-declared copy assignment operator,
  • X does not have a user-declared move assignment operator, and
  • X does not have a user-declared destructor,
  • the move constructor would not be implicitly defined as deleted.

C++ Rule of Zero & what is "user-declared" constructor? Archive du 04/12/2016 le 09/03/2020

Par does not have a user-declared, cela signifie aussi ne pas utiliser = default;.

Autre explication : The Rule of Zero in C++ Archive du 23/04/2019 le 18/12/2019

Version "Rule of five"

Cette version est nécessaire si la classe gère des ressources ou si la classe est polymorphique.

class Class
{
 public:
  // Constructor.
  Class() = default;
  // Destructor.
  ~Class() = default;
  // Move constructor.
  Class(Class && other) noexcept = delete;
  // Copy constructor.
  Class(Class const& other) = delete;
  // Move operator.
  Class& operator=(Class && other) noexcept = delete;
  // Copy operator.
  Class& operator=(Class const& other) = delete;
}

Cette règle impose de définir ces 5 méthodes. Pour des raisons de simplicité lors de la déclaration initiale d'une classe, je déclare le constructeur = default et les 4 autres en = delete puis je les implémente lorsque nécessaire.

L'intérêt de tous les delete est de maitriser le compilateur et de n'écrire que le code nécessaire.

Il est préférable d'avoir les opérations move en noexcept. Sinon, le compilateur peut décider d'appeler l'opérateur copie à la place.

C++ noexcept and move constructors effect on performance in STL Containers Archive du 01/12/2017 le 18/12/2019

Destructeur

Une classe doit avoir un destruteur virtuel si l'héritage est utilisée et que l'une des classes (elle, enfant ou parent) gère des ressources.

L'attribut virtual doit être placé dans la classe la plus parent.

struct A
{
  ~A() = default;
};
 
struct B : public A
{
  virtual ~B() {std::cout << "coucou" << std::endl;}
};
 
int main( int argc, char** argv )
{
  A* t = new B();
  // On n'appelle pas "coucou" bien que son destructeur soit virtuel
  delete t;
}

Il faut de même utiliser virtual si l'un des membres est une classe qui gère des ressources.

class TestClass
{
 public:
  TestClass() {std::cout << "const" << std::endl;}
  ~TestClass() {std::cout << "destruct" << std::endl;}
};
 
struct A
{
  ~A() = default;
};
 
struct B : public A
{
  TestClass t;
};
 
int main( int argc, char** argv )
{
  A* a = new B();
  // Le destructeur de TestClass de B n'est pas appelé.
  delete a;
}

Sortie sans le destructeur de A virtual :

const
  • Durée de vie d'un objet dans la pile

Si c'est un objet explicitement déclaré, le destructeur sera appelé lors du bloc fermant.

int main()
{
  A a;
  {
    B b;
    // On appelle ~B()
  }
  // On appelle ~A()
}

La technique de mettre un bloc de crochet pour forcer un appel du destructeur à un moment précis est utilisé fréquemment par les std::scoped_lock.

int main()
{
  std::mutex io_mutex;
  {
    std::scoped_lock<std::mutex> lk(io_mutex);
    std::cout << e1.id << " and " << e2.id << " are waiting for locks" << std::endl;
    // le mutex est automatiquement libéré lors de l'appel du destructeur de lk.
  }
  return 1;
}

Si l'objet est temporaire, le destructeur sera appelé lorsque la totalité de l'instruction sera finie.

#include <iostream>
 
class A
{
public:
  A() noexcept { std::cout << "A\n"; }
  ~A() noexcept { std::cout << "~A\n"; }
  int foo() { return 1; }
};
 
int foo2(int a)
{
  std::cout << "int\n";
  return a;
}
 
int main()
{
  std::cout << "0\n";
  foo2(foo2(A().foo()));
  // Le destructeur de A est appelé ici.
  std::cout << "1\n";
}

Affichage :

0
A
int
int
~A
1

Les 4 opérateurs copy/move constructor/assignment operator

On part du principe que les 4 opérateurs sont toujours définis explicitement (y compris via = default).

  • Par défault, copy et move font la même chose

Avec l'implémentation = default, la différence entre copy et move est le fait qu'avec move, on s'épargne une allocation mémoire.

Contrairement aux classes qui définissent les opérateurs move, la version par défaut move ne modifie pas l'objet initial.

Exemple avec move = default :

#include <algorithm>
 
class TestClass
{
 public:
  TestClass() {a_ = new int[1];}
  TestClass(TestClass const& other) = delete;
  TestClass(TestClass && other) noexcept = default;
  TestClass& operator=(TestClass const& other) = delete;
  TestClass& operator=(TestClass && other) noexcept = default;
  ~TestClass() {delete[] a_;}
 public:
  int* a_ = nullptr;
};
 
int main( int argc, char** argv )
{
  TestClass t;
  TestClass t2 = std::move(t);
  // Après std::move, t.a_ vaut toujours la valeur d'origine car le constructeur copy vaut default.
  // Il y a donc un double delete.
}

Exemple avec move et std::exchange :

#include <algorithm>
#include <utility>
 
class TestClass
{
 public:
  TestClass() {a_ = new int[1];}
  TestClass(TestClass const& other) = delete;
  TestClass(TestClass && other) noexcept
  {
    this->a_ = std::exchange(other.a_, {});
  }
  TestClass& operator=(TestClass const& other) = delete;
  TestClass& operator=(TestClass && other) noexcept
  {
    this->a_ = std::exchange(other.a_, {});
    return *this;
  }
  ~TestClass() {delete[] a_;}
 public:
  int* a_ = nullptr;
};
 
int main( int argc, char** argv )
{
  TestClass t;
  TestClass t2 = std::move(t);
}
  • Mettre les 2 copy en = delete

L'opérateur copy devrait toujours être delete si la classe est polymorphique pour éviter les problèmes de slicing. C.67: A polymorphic class should suppress copying

Mauvais :

class B {
 public:
  virtual char m() { return 'B'; }
};
 
class D : public B {
 public:
  char m() override { return 'D'; }
};
 
void f(B& b) {
  auto b2 = b; // oops, slices the object; b2.m() will return 'B'
}
 
D d;
f(d);

OK :

#include <memory>
 
class B {
 public:
  B() = default;
  // Si on implémente ce clone, il faut mettre le constructeur par copie en public.
  // Il est préférable de mettre ce constructeur en privée pour empêcher
  // sa mauvaise utilisation.
  virtual std::unique_ptr<B> clone() = 0;
  virtual char m() { return 1; }
  virtual ~B() = default;
 
 protected:
  // Peut être protected si clone est pure virtuelle.
  B(const B&) = default;
  B& operator=(const B&) = delete;
};
 
class D : public B {
 public:
  D() = default;
  D(const D&) = default;
  D& operator=(const D&) = delete;
  std::unique_ptr<B> clone() override { return std::make_unique<D>(*this); }
  char m() override { return 10; }
  virtual ~D() = default;
};
 
char f(B& b) {
  auto b2 = b.clone();
  return b2->m();
}
 
int main() {
  D d;
  return f(d);
}
  • copy si move non défini.

Si on implémente l'opérateur copy et que l'opérateur move n'est pas explicitement = delete, l'opérateur move va appeler l'opérateur copy. On note qu'on est dans le cas où l'opérateur move ne sera pas automatiquement généré par le compilateur.

class TestClass
{
 public:
  TestClass() {std::cout << "const" << std::endl;}
  TestClass(TestClass const& other) {std::cout << "copy" << std::endl;}
  ~TestClass() {std::cout << "destruct" << std::endl;}
};
 
int main( int argc, char** argv )
{
  TestClass t;
  TestClass t2 = std::move(t);
}

Sortie :

const
copy
destruct
destruct

Contenu

En plus de ces 6 opérateurs, une classe peut contenir (dans l'ordre de préférence, conformément au coding style de Google) :

  • Des déclarations de type (typedef, using, et struct et class imbriquées),
  • Des constantes,
  • Des méthodes de fabrique (pattern factory),
  • Des constructeurs,
  • Des opérateurs copy / move,
  • Un destructeur,
  • Des méthodes diverses,
  • Des champs / variables / attributs / membres (c'est la même chose) divers.
  • Des méthodes.

Champs / variables / attributs / membres

Même si un membre n'est pas modifié (sauf dans le constructeur), il est déconseillé de le déclarer const. Cela empêche l'utilisation de l'opérateur std::move et force l'utilisation de std::copy dès qu'un membre n'a pas de constructeur trivial.

#include <iostream>
#include <string>
 
class A
{
 public:
  A(int i, const std::string& str) : i_(i), s_(str) {}
 
  // Destructor.
  ~A() = default;
  // Move constructor.
  A(A && other) noexcept = default;
  // Copy constructor.
  A(A const& other) = delete;
  // Move operator.
  A& operator=(A && other) noexcept = delete;
  // Copy operator.
  A& operator=(A const& other) = delete;
 
 public:
  int i_;
  const std::string s_;
};
 
int main()
{
  A a(5, "coucou");
  A aa = std::move(a);
 
  std::cout << a.i_ << a.s_ << "\n";
  std::cout << aa.i_ << aa.s_ << "\n";
}

Message du compilateur :

error C2280: 'A::A(const A &)': attempting to reference a deleted function
note: see declaration of 'A::A'
note: 'A::A(const A &)': function was explicitly deleted

Comme la classe à un champ const et non modifiable, il va appeler std::copy (qui est delete) à la place de std::move.

Visibilité

Il existe 3 niveaux de visibilité :

  • public : tout le monde peut accéder à l'élément.
  • protected : seules la classe et les classes enfant (héritage) peuvent accéder à l'élément.
  • private : seule la classe peut accéder à l'élément.

Après avoir déclaré un niveau de visibilité, tous les éléments en dessous ont la même visibilité.

class Class
{
 public:
  Class() = default;
 
 protected:
  void Execute();
 
 private:
  int etat;
}

En l'absence de visibilité précédant un élément, ils sont private pour les class et public pour les struct. C'est la seule et unique différence entre struct et class.

On classe d'abord en fonction de la visibilité puis en fonction du type de l'élément.

impl

Voir le pattern pont

Héritage

Syntaxe

#include <iostream>
#include <memory>
 
class A
{
 public:
  A() = default;
  /* virtual */ ~A() { std::cout << "A\n"; }
  // On applique la règle des 5 car un destructeur est défini.
  A(A&& other) noexcept = default;
  A(A const& other) = delete;
  A& operator=(A&& other) noexcept = default;
  A& operator=(A const& other) = delete;
};
 
class B : public A
{
 public:
  B() = default;
  ~B() /* override */ { std::cout << "B\n"; }
  // On applique la règle des 5 car un destructeur est défini.
  B(B&& other) noexcept = default;
  B(B const& other) = delete;
  B& operator=(B&& other) noexcept = default;
  B& operator=(B const& other) = delete;
 
 private:
  A a_;
};
 
int main()
{
  std::unique_ptr<A> a = std::make_unique<B>();
  // Comme le destructeur n'est pas virtuel et que a est déclarée en A,
  // le destructeur de B n'est pas appelé ni le destructeur de la variable privée a_.
}

vtable

Il n'existe pas de norme pour savoir où est l'adresse du pointeur de la table virtuelle. Cependant, en tout logique, c'est la première donnée de la classe.

#include <iostream>
 
struct S {
  int x;
  virtual void f() {}
};
 
int main() {
  S s;
  s.x = 5;
  std::cout << "size : " << sizeof(S) << "\n";
  void*** ptr = (void***)&s;
  std::cout << "address : " << ptr << "\n";
  std::cout << "bytes : " << *ptr << " and " << *(ptr + 1) << "\n";
  std::cout << "address if vftable : " << *ptr << "\naddress of f : " << **ptr
            << "\n";
  std::cin.get();
  return 0;
}

detemine vtable address Archive du 13/12/2010 le 22/06/2021

override / redéfinition

Cas général

Une classe enfant redéfinit une méthode du parent. Cela peut se faire avec ou sans l'attribut virtual.

#include <iostream>
 
class A
{
 public:
  /*virtual*/ void f() { std::cout << "A" << std::endl;}
};
 
class B : public A
{
 public:
  void f() /*override*/ { std::cout << "B" << std::endl;}
};
 
int main()
{
  B b;
  A *a = &b;
  // Sans virtual/override, on affiche "A".
  // Avec virtual/override, on affiche "B".
  a->f();
  b.f();
}

Constructeur/destructeur

  • Constructeur

Lors de l'instantiation d'une classe, un constructeur sera systématiquement appelé à chaque niveau d'héritage, en commençant par celui en bas de la hiérarchie.

  • Destructeur

Dans le cas général d'un appel de fonction virtual, c'est la méthode de la classe la plus enfant qui sera appelée.

Pour le destructeur, toujours s'il est virtual, tous les destructeurs seront appelés, de l'enfant vers le parent.

Par défaut, un destructeur est noexcept. Il est donc interdit d'y générer une exception. Il est possible d'ajouter noexcept(false) mais c'est une mauvaise pratique de codage.

overload / surcharge

  • Cas général

Deux méthodes ont le même nom mais avec des arguments de type différent.

#include <iostream>
 
void f() { std::cout << "void" << std::endl;}
void f(int) { std::cout << "int" << std::endl;}
 
int main()
{
  f();
  f(1);
}

Attention, il n'est pas possible d'avoir deux méthodes avec pour seule différence le type de retour (prototype identique).

// Ambigu et interdit
void f() { std::cout << "void" << std::endl;}
int f() { std::cout << "int" << std::endl;}

Il est déconseillé de mettre des valeurs par défaut aux arguments. Il est préférable de privilégier une surcharge.

void f() { f(5);}
void f(int i/* = 5*/) { std::cout << "int" << std::endl;}

Default function arguments are the devil Archive du 18/04/2020 le 20/04/2020

  • Cas de deux méthodes ayant le même prototype compatible
void fn(int i, int j);
void fn(int i, int ...);

Dans le cas de l'appel à la fonction fn(1, 2), la fonction ayant le prototype exact sera appelée : void fn(int i, int j)

  • Héritage

Le compilateur ne sait pas gérer la surcharge à travers l'héritage.

#include <iostream>
#include <string>
 
struct BaseInt
{
  void Func(int) { std::cout << "BaseInt...\n"; }
  void Func(double) { std::cout << "BaseDouble...\n"; }
};
 
struct BaseString
{
  void Func(std::string) { std::cout << "BaseString...\n"; }
};
 
struct Derived : public BaseInt, public BaseString
{
};
 
int main()
{
  Derived d;
  d.Func(10.);
}

Cela donne le message d'erreur :

main.cpp:21:5: error: request for member ‘Func’ is ambiguous

Il est nécessaire d'ajouter explicitement les méthodes via using.

struct Derived : public BaseInt, public BaseString
{
  using BaseInt::Func;
  using BaseString::Func;
};

Interface

Il n'y a pas une unique méthode pour définir une interface.

Classe abstraite

Il faut faire une classe avec tous les prototypes et les déclarer toutes virtuelles pures.

Foo et Bar doivent déclarer au minimum les méthodes de BaseClass.

#include <iostream>
#include <memory>
#include <string>
#include <vector>
 
struct BaseClass
{
  virtual ~BaseClass() = default;
  virtual std::string getName() const = 0;
};
 
struct Bar : BaseClass
{
  std::string getName() const override
  {
    return "Bar";
  }
};
 
struct Foo : BaseClass
{
  std::string getName() const override
  {
    return "Foo";
  }
};
 
int main()
{
  std::vector<std::unique_ptr<BaseClass>> vec;
  vec.emplace_back(std::make_unique<Foo>());
  vec.emplace_back(std::make_unique<Bar>());
 
  for (const auto& v : vec) std::cout << v->getName() << std::endl;
 
  std::cout << std::endl;
}

Template

Foo et Bar n'héritent pas explicitement d'une classe commune. Ce mécanisme est caché dans un objet opaque (type-erasure).

Ce système de masquage ne doit être appliqué que si nécessaire.

Where possible, avoid type-erasure

Ici, Foo, Bar et aussi Object et Model doivent déclarer au minimum les méthodes de BaseClass.

#include <iostream>
#include <memory>
#include <string>
#include <vector>
 
class Object
{
 public:
  template <typename T>
  Object(std::unique_ptr<T> obj) : object(std::make_unique<Model<T>>(std::move(obj))) {}
 
  std::string getName() const
  {
    return object->getName();
  }
 
  struct Concept
  {
    virtual ~Concept() {}
    virtual std::string getName() const = 0;
  };
 
  template< typename T >
  struct Model final : Concept
  {
    Model(std::unique_ptr<T> t) noexcept : object(std::move(t)) {}
    std::string getName() const final
    {
      return object->getName();
    }
   private:
    std::unique_ptr<T> object;
  };
 
  std::unique_ptr<const Concept> object;
};
 
struct Bar
{
  std::string getName() const
  {
    return "Bar";
  }
};
 
struct Foo
{
  std::string getName() const
  {
    return "Foo";
  }
};
 
int main()
{
  std::vector<std::unique_ptr<Object>> vec;
  vec.emplace_back(std::make_unique<Object>(std::make_unique<Foo>()));
  vec.emplace_back(std::make_unique<Object>(std::make_unique<Bar>()));
 
  for (const auto& v : vec)
    std::cout << v->getName() << std::endl;
}

C++ Core Guidelines: Type Erasure with Templates Archive du 17/09/2018 le 17/05/2020

Hack

Méthodes statiques dans une interface

Normalement, c'est interdit mais apparemment, c'est possible en faisant du bricolage.

Static Interfaces in C++ Archive du 13/08/2019 le 19/12/2019

L'interface :

template < typename T >
class StaticInterface
{
  public:
    StaticInterface()
    {
      int(*fooCheck)(int)   = T::foo;
      bool(*barCheck)(bool) = T::bar;
    }
};

Une implémentation de la classe :

class DerivedClass : public StaticInterface<DerivedClass>
{
  public:
    static int foo(int  param){ return 10; }
    static bool bar(bool param){ return 20; }
};

Implémentation d'une méthode purement virtuelle d'un parent par un autre parent

Il est important que Base ne contienne que les méthodes virtuelles car les deux classes Mixin et Example vont en hériter.

Si Base::test n'est pas purement virtuelle, ça sera toujours la méthode de la classe Mixin qui sera appelée.

class Base {
public:
    virtual void test() = 0;
};
 
template <typename T>
class Mixin : virtual T {
public:
    virtual void test() override { /*... do stuff ... */ }
};
 
class Example : public virtual Base, public virtual Mixin<Base> {
    /* definitions specific to the Example class _not_including_
       a definition of the test() method */
};

How to override a function in another base class? Archive du 22/10/2013 le 19/12/2019

Forcer un template à hériter d'une classe

template <class M>
class InterfaceVisitable {
  static_assert(std::is_base_of<XXXXXXXX, M>::value,
                "M must be a descendant of XXXXXXXX");
};

Caster T<Base>* t = new T<Derive>()

C'est impossible. La notion d'héritage ne marche que pour la classe, pas pour les arguments du template.

L'exemple ci-dessous ne marche pas. Caster l'un en l'autre poserait des problèmes pour l'accès à la variable a.

#include <cstddef>
#include <iostream>
 
class A
{
 public:
  int a;
};
 
class B : public A
{
 public:
  int b;
};
 
template<typename T>
class C
{
 public:
  T t[2];
};
 
int main()
{
  C<A> a;
  C<B> b;
 
  std::cout << (((size_t)(&a.t[1])) - ((size_t)(&a.t[0]))) << std::endl;
  std::cout << (((size_t)(&b.t[1])) - ((size_t)(&b.t[0]))) << std::endl;
 
  return 0;
}

Rendu :

4
8

Exécuter une méthode privée

#include <iostream>
 
class Widget {
 private:
  void forbidden() {
    std::cout << "Whoops...\n";
  }
};
 
namespace {
  struct TranslationUnitTag {};
}
 
void hijack(Widget& w);
 
template <
  typename Tag,
  typename ForbiddenFun,
  ForbiddenFun forbidden_fun
>
class HijackImpl {
  friend void hijack(Widget& w) {
    (w.*forbidden_fun)();
  }
};
 
template class HijackImpl<
  TranslationUnitTag,
  decltype(&Widget::forbidden),
  &Widget::forbidden
>;
 
int main() {
  Widget w;
  hijack(w); // Prints "Whoops..."
}

Profiting from the Folly of Others Archive du 04/2020 le 17/04/2020

TODO

lang/cpp/classes.txt · Dernière modification : 2022/07/04 10:05 de root