Les catégories de classes

Il existe beaucoup de différences entre les structures en C et les structures ou classes en C++. La seule différence entre les mot-clés struct et class en C++ est la visibilité de leur contenu par défaut : public pour struct, et private pour class.

Pour qu'une structure en C++ ait les mêmes propriétés qu'une structure en C, il faut qu'elle soit trivial et standard layout : on parle alors de Plain Old Data (POD).

Les méthodes non virtuelles, et les variables ou fonctions statiques d'une classe n'affecte pas sa catégorie puisque cela ne change pas l'objet en lui-même.

Trivial

En C, les structures existent dès leur allocation et jusqu'à leur déallocation, alors qu'en C++ elles existent après leur constructeur, et jusqu'à leur destructeur. De plus, seul l'assignement par copie est généré par le compilateur pour les structures en C (afin de pouvoir écrire struct_t s1 = s2; ). En C++, le compilateur génère jusqu'à six fonctions :

  • Le constructeur par défaut : T::T()

  • Le constructeur par copie : T::T(T const& lhs)

  • Le constructeur par move : T::T(T&& lhs)

  • L'assignement par copie : T& T::operator=(T const& lhs)

  • L'assignement par move : T& T::operator=(T&& lhs)

  • Le destructeur : T::~T()

On peut explicitement supprimer ou générer les fonctions du compilateur :

struct Copyable {
    Copyable(Copyable&&) = delete;
    Copyable(Copyable const&) = default;
};

Lorsqu'elles sont définies par le compilateur, ces fonctions peuvent être trivial : dans ce cas, le constructeur par défaut et le destructeur ne font rien, et les opérations par copie ou move ne font que copier le contenu de l'objet de manière semblable à memcpy. Pour cela, il faut que la classe ne contienne pas de méthodes virtuelles, et ne possède ou n'hérite que de classes elles-même trivial.

Lorsque le concept TriviallyCopyable est respecté, les objets peuvent être copiés avec memcpy et peuvent être sérialisés de manière semblable (comme avec ifstream.read() ou ofstream.write()). Lorsqu'en plus le constructeur par défaut est trivial, la classe est trivial.

struct Trivial {
    int values[3];
private:
    char const* name;
};

struct NonTrivial {
    char data[16];
    NonTrivial() {}
};

Standard layout

Les classes en C++ ne sont pas forcément représentées de la même manière en mémoire que les classes en C. Afin d'avoir une classe standard layout, il faut qu'elle n'utilise pas d'héritage (ou alors de façon très restreinte), qu'elle ne soit composée que de membres eux-mêmes standard layout, qu'elle n'ait pas de fonctions virtuelles et que tous ses membres aient la même visibilité (tous public, private ou protected).

Ces classes peuvent être pratiques pour interagir avec un programme en C, et la macro offsetof(Type, m) permet d'obtenir l'offset entre l'adresse d'un objet Type et l'adresse de sa variable m.

struct StandardLayout {
    int i;
    float f;
    StandardLayout(int value) : i(value + 1), f(value - 1.f) {}
};

struct NonStandardLayout {
    int i;
private:
    float f;
};

La représentation en mémoire des classes peut être modifiée pour réduire leur taille en mémoire ou accélérer l'accès à leurs données, notamment en changeant l'ordre de déclaration des membres ou en utilisant des bitfields pour représenter plusieurs données sur un nombre précis de bits.

struct LargeStruct { // sizeof(LargeStruct) = 24
    char c1;
    long long l;
    char c2;
};

struct SmallStruct { // sizeof(SmallStruct) = 16
    int val;
    char c1;
    char c2;
    // 3 valeurs sur 16 bits
    unsigned short b1: 1;
    unsigned short b2: 6;
    unsigned short b3: 9;
    long long l;
};

sizeof(T) doit être un multiple de alignof(T), notamment pour stocker des objets de même type dans un tableau, mais cela peut alors augmenter la taille des objets.

Ranger les membres d'une classe par contraintes d'alignement croissantes ou décroissantes assure la plus petite taille en mémoire possible pour le type donné.

Les aggrégats et brace constructors

Si une classe ne possède que des membres publics et aucun constructeur, le compilateur génère un constructeur spécial pour cette classe, qui est alors un aggregate. Ce constructeur permet d'initialiser les membres de la classe dans l'ordre où ils sont déclarés. Cette catégorie est beaucoup plus large que les deux précédentes, parce que les membres des aggrégats n'ont pas à être eux-mêmes des aggrégats :

struct Aggregate {
    int id;
    std::string message;
    int id2;
};
// id2 n'est pas spécifié et est donc initialisé par défaut (égal à zéro).
Aggregate agg{ 42, "Hello" };

Depuis C++17, les aggrégats peuvent avoir de l'héritage non virtuel public.

Ce constructeur ne peut être appelé avec des parenthèses, mais doit utiliser des accolades : il s'agit du 'brace constructor'. Utiliser cette notation peut remplacer n'importe quel constructeur avec les parenthèses, avec quelques particularités :

  • Comme vu au-dessus, il peut également correspondre au constructeur d'aggégats

  • Il interdit les narrowing conversion de ses paramètres

  • Si il prend au moins un paramètre, il sélectionne en priorité les constructeurs prenant un std::initializer_list<T>

  • Il permet de ne pas mentionner le type de l'objet construit selon le contexte

struct MyClass {
    MyClass(std::initializer_list<int> args);
    MyClass() = default;
    explicit MyClass(std::string const& name, float f);
    MyClass(int id);
}

MyClass make_object() {
    // Constructeur par défaut ; pas besoin de mentionner le type.
    return {};
    // Constructeur prenant une std::initializer_list<int>.
    return { 1 };
    // Constructeur prenant un int.
    return MyClass(1);
    // On doit spécifier le type pour les constructeurs explicites.
    return MyClass{ "hello", 3.14f };
}

void use_object(MyClass&& obj);

int main() {
    // Pas besoin de mentionner le type.
    use_object({ 1u, 1ll });
    // Narrowing conversion de float en int non autorisée.
    // use_object({ 1, 1.f });
}

La librairie standard contient des traits permettant de vérifier les catégories auxquelles appartient une classe donnée à la compilation : std::is_trivial, std::is_trivially_default_constructible (existe pour chacune des fonctions potentiellement trivial), std::is_standard_layout, std::is_aggregate, ou encore std::is_pod (déprécié, ne fait que combiner std::is_trivial et std::is_standard_layout).

Pour aller plus loin

Il existe également des traits pour voir si une classe possède des méthodes virtuelles (std::is_polymorphic), et si cette classe peut être instanciée (c'est-à-dire qu'aucune méthode virtuelle n'est pure, avec std::is_abstract).

Les représentations des objets en mémoire peuvent être manipulées pour permettre du type punning (comme avec les union ou reinterpret_cast). Attention cependant, de nombreuses manipulations ne sont pas garanties par le standard et on se restreint généralement aux types TriviallyCopyable.

Un article de Stack Overflow détaille plus précisément les catégories standard layout, trivial et aggregate.

Challenge

(**) Créer une classe tagged_ptr<T> qui permet de stocker et de manipuler un compteur à l'intérieur d'un pointeur de type T*. Exemple :

// Change l'objet pointé par ptr et incrémente son compteur.
// L'ancienne valeur du compteur est renvoyée.
int actualize(tagged_ptr<T>& ptr, T const& value) {
    static_assert(sizeof(T*) == sizeof(ptr));
    int counter = ptr.get_counter();
    *ptr = value;
    ptr.increment_counter();
    return counter;
}

Bonus : rendre la classe covariante.

Last updated