Mokona Guu Center

Un piège n'est qu'un piège

Publié le

C'est chez Daz (archive) que j'ai vu en premier la présentation « Pitfalls of OOP », puis je l'ai reçue de diverses sources dans les jours qui ont suivi, je l'ai vu sur d'autres sites. Souvent, les commentaires incluaient quelque chose comme : il faut éviter la programmation orientée objet dans les jeux vidéo.

Ce commentaire à l'emporte pièce (la programmation dans le jeux vidéo couvre divers domaines avec des besoins différents) m'a immédiatement agacé pour deux raisons. Le première est que ce n'est absolument pas le sujet de la présentation, qui parle de certains pièges et de comment les éviter, la seconde est que les solutions pour éviter ces pièges peut très bien passer par de la programmation objet.

Le sujet de la présentation est tout à fait juste. Pas récent, mais comme à chaque fois que quelqu'un sort une présentation sur la gestion mémoire, une grande partie de la population des programmeurs découvre que gérer sa mémoire n'est pas simple et est extrêmement important dans les jeux vidéos (ceux qui ont besoin de performances en tout cas).

Je n'ai pas grand chose à dire sur la présentation. Elle est bien faite, je trouve les explications claires et les conclusions sont justes. À un bémol prêt (Optimize for Data first, then Code) : s'il est important de prévoir de ne pas faire n'importe quoi avec sa mémoire dès le début d'un développement, il est fort peu probable qu'une équipe ait toute l'expérience nécessaire sur console de jeu pour trouver le meilleur modèle du premier coup. Et justement, une programmation objet peut fortement aider !

C'est sur les commentaires sur la présentation que j'ai des commentaires à faire : à vrai dire, je pense qu'elle a été mal lue, ou mal comprise. Peut-être est-ce tout simplement le terme de « Data Oriented Design » qui a brouillé les pistes. En voyant ce terme, certains lecteurs se sont visiblement dit que cela remplaçait la « Programmation Orientée Objet ».

Cependant, ces deux concepts sont orthogonaux ! La « Programmation Orientée Objet » implique des paradigmes de programmation qui peuvent être utilisés de diverses manières. Et une utilisation d'une certaine manière est un design, une architecture. Le « Data Oriented Design » que je traduirais par « Architecture Orientée Données » n'est donc qu'une manière d'agencer son programme, ses objets dans le cas de la POO.

La POO n'implique pas une architecture précise, il n'y a qu'à regarder deux ou trois programmes venant de personnes ou entreprises différentes pour voir que les choix d'architectures peuvent être nombreux. Un exemple classique est celui de l'architecture de moteur de jeu à hiérarchie profonde contre celle à composant (une piste par ici).

Là où la présentation tend le bâton pour se faire battre, c'est qu'elle commence par un exemple d'architecture extrêmement simple, naïve, pour montrer l'impact négatif sur la mémoire. L'ennui étant que cette architecture naïve est très souvent utilisée, par les débutants bien sûr, mais pas qu'eux... Dans cette architecture, on utilise une encapsulation très forte : un objet a la propriété de tous ses éléments.

La suite de la présentation s'attache à comment agencer sa mémoire, mais ne donne plus d'exemple complet. C'est normal, adapter l'architecture à la POO n'est pas le sujet de la présentation.

Et cela donne le raccourci : l'exemple POO au début est mauvais, la solution de l'agencement mémoire à la fin est bonne, donc la POO est mauvaise. Pas de chance, la conclusion n'est pas la bonne, la conclusion est : donc l'agencement mémoire du début est mauvais.

Un exemple

Prenons un exemple un peu bateau pour voir comment on peut changer la façon dont des données sont stockées en mémoire tout en gardant une programmation objet.

Attention : moi, je ne parlerai pas de cache miss, je vais juste montrer sur un exemple que l'on peut changer de structure de stockage en mémoire tout en restant dans de la programmation objet.

Les exemples seront simples, pour donner une idée, l'architecture sera loin d'être aussi bien qu'elle pourrait l'être, et chaque exemple sera dans un fichier (autant pour l'architecture physique d'un projet). Chaque propos en son temps :)

L'énoncé de l'exemple est simple : j'ai une classe People qui stocke un nom, chaque instance représente donc une personne avec son nom.

Dans un système, je vais avoir plusieurs personnes (People) et je veux connaître le nombre d'utilisation pour chaque prénom.

L'exemple est très simplifiée donc, mais on a plusieurs concepts : une classe, une liste d'instance de cette classe, et une fonction de comptage.

Niveau 0 : naïf que je suis

#include <iostream>
#include <string>
#include <list>
#include <map>

class People {
public:
    People(const std::string& name) : name(name) {}

    const std::string& GetName() const {
        return name;
    }

private:
    const std::string name;
};

class NameCounter {
public:
    NameCounter(const std::list<People*>& peopleList) {
        std::list<People*>::const_iterator it = peopleList.begin();
        std::list<People*>::const_iterator itEnd = peopleList.end();

        while (it != itEnd) {
            People* people = *it;

            if (count.count(people->GetName())) {
                count.insert(std::pair<std::string, int>(people->GetName(), 0));
            }

            ++count[people->GetName()];
            ++it;
        }
    }

    void Print() {
        std::map<std::string, int>::const_iterator it = count.begin();
        std::map<std::string, int>::const_iterator itEnd = count.end();

        while (it != itEnd) {
            const std::pair<const std::string, int>& entry = *it;

            std::cout << "Name: " << entry.first << " with count: " << entry.second << std::endl;
            ++it;
        }
    }

private:
    std::map<std::string, int> count;
};

void CleanList(std::list<People*>& peopleList) {
    std::list<People*>::const_iterator it = peopleList.begin();
    std::list<People*>::const_iterator itEnd = peopleList.end();

    while (it != itEnd) {
        delete* it;
        ++it;
    }
}

int main(int argc, const char* argv[]) {
    std::list<People*> peopleList;

    peopleList.push_back(new People("Pierre"));
    peopleList.push_back(new People("Paul"));
    peopleList.push_back(new People("Jean"));
    peopleList.push_back(new People("Paul"));

    for (int i = 0; i < 1000; ++i) {
        peopleList.push_back(new People("Luc"));

        NameCounter counter(peopleList);
        counter.Print();
    }

    CleanList(peopleList);

    return 0;
}

Dans cette première version, j'utilise une structure de données très simple pour People. Puisque chaque personne a un nom, People stocke le nom directement, il a la propriété de l'objet std::string. J'initialise ma liste (une bête std::list) avec le résultat de new (qui pourraient venir d'ailleurs). Pour le comptage, je parcours toute la liste et je stocke dans une std::map une paire (nom, occurrences).

J'affiche ensuite 1000 fois le résultat en ajoutant une personne à chaque fois, histoire de mettre un peu de dynamisme à l'exemple.

Ce niveau est globalement ce que l'on trouve le plus souvent. Du point de vu de cet article, il y a deux soucis : tout n'est pas encapsulé (new est un détail d'implémentation de la création des objets, la destruction explicite de CleanList n'a rien à faire dans main, l'utilisation de std::list ne devrait pas être visible) et les données sont dispersées en mémoire.

Encapsulons !

Première étape pour nous permettre de modifier tranquillement les structures de données sans modifier le code client : encapsuler ! Il faudrait ajouter : bien définir ses interfaces, nous nous en passerons ici, ce n'est pas le sujet.

J'ajoute donc un PeopleList qui contiendra une liste de personnes. On peut ajouter un élément à cette liste et obtenir la liste interne. Les éléments sont ajoutés via un nom, la construction de l'objet étant interne. Sur ce point, une exemple plus complet ferait probablement différemment, mais restons simple. L'obtention de la liste interne doit par contre attirer le regard : elle brise l'encapsulation en donnant accès à des détails interne.

Ce n'est pas très grave car c'est temporaire et non utilisé par le code client (ici, main).

Il faut bien entendu apporter quelques modifications à NameCounter pour recevoir une PeopleList.

À partir de maintenant, le code client ne bougera plus. Cependant, nous pourrons bouger les structures de données interne au système sans changer son fonctionnement (en fait, en l'accélérant beaucoup).

#include <iostream>
#include <string>
#include <list>
#include <map>

class People {
public:
    People(const std::string& name) : name(name) {}

    const std::string& GetName() const {
        return name;
    }

private:
    const std::string name;
};

class PeopleList {
public:
    PeopleList() {}

    ~PeopleList() {
        CleanList();
    }

    void Add(const std::string& name) {
        peopleList.push_back(new People(name));
    }

    const std::list<People*>& GetList() const {
        return peopleList;
    }

private:
    std::list<People*> peopleList;

    void CleanList() {
        std::list<People*>::const_iterator it = peopleList.begin();
        std::list<People*>::const_iterator itEnd = peopleList.end();

        while (it != itEnd) {
            delete *it;
            ++it;
        }
    }
};

class NameCounter {
public:
    NameCounter(const PeopleList& peopleList) {
        const std::list<People*> internalList = peopleList.GetList();
        std::list<People*>::const_iterator it = internalList.begin();
        std::list<People*>::const_iterator itEnd = internalList.end();

        while (it != itEnd) {
            People* people = *it;

            if (count.count(people->GetName())) {
                count.insert(std::pair<std::string, int>(people->GetName(), 0));
            }

            ++count[people->GetName()];
            ++it;
        }
    }

    void Print() {
        std::map<std::string, int>::const_iterator it = count.begin();
        std::map<std::string, int>::const_iterator itEnd = count.end();

        while (it != itEnd) {
            const std::pair<const std::string, int>& entry = *it;

            std::cout << "Name: " << entry.first << " with count: " << entry.second << std::endl;
            ++it;
        }
    }

private:
    std::map<std::string, int> count;
};

int main(int argc, const char* argv[]) {
    PeopleList peopleList;

    peopleList.Add("Pierre");
    peopleList.Add("Paul");
    peopleList.Add("Jean");
    peopleList.Add("Pierre");

    for (int i = 0; i < 1000; ++i) {
        peopleList.Add("Luc");

        NameCounter counter(peopleList);
        counter.Print();
    }

    return 0;
}

Premier mouvement

Dans le premier design, People avait la propriété du nom. L'énoncé de l'exemple donnait un indice sur le fait que cela n'était pas la meilleure idée : il va y avoir des doublons !

Ainsi, dans les programmes précédents, il y a 1000 fois une chaîne \"Luc\" en mémoire, alors même que le champ name de People est constant (People est une classe non mutable : une fois qu'elle est créée, ses valeurs ne changent pas).

Plutôt que de stocker un nom dans chaque instance de People, je crée donc un dictionnaire de nom dont PeopleList a une instance. Lors de la création d'une instance de People, le nom est tout d'abord ajouté au dictionnaire s'il n'est pas déjà présent, puis l'instance de People est créée avec pour nom une référence vers l'entrée du dictionnaire.

On y gagne en place utilisée (un peu plus de 80ko pour la version précédente pour environ 64ko maintenant). Forcément, plus un même prénom est utilisé, plus cette structure est rentable.

Mais elle est de toute façon flexible, la seule contrainte est que le stockage des noms dans le dictionnaire soit stable. Comme People a une référence vers une entrée de dictionnaire, cette entrée ne doit pas changer en mémoire. C'est pour cette raison que dans cette implémentation, j'utilise une std::list. Si le nombre de prénom maximum était connu, un tableau ferait l'affaire. Évidemment, d'un point de vue cache, le std::list n'est pas un très bon choix. Mais comme le dictionnaire n'expose pas ses détails d'implémentation, ceux-ci pourraient être modifiés pour une meilleure disposition en mémoire sans rien changer au reste du programme.

À partir de maintenant, la classe People ne bougera plus. On notera que l'on peut toujours l'utiliser comme un objet normal. Simplement, ses données étant encapsulées, le client n'a pas à se soucier de leur stockage. À noter d'ailleurs que mis à part un changement de nom pour le paramètre du constructeur, l'interface de People n'a pas changé. Du code client écrit utilisant People n'aurait pas à être réécrit alors que, en-dessous, nous avons remanié grandement le stockage de ses données !

#include <iostream>
#include <string>
#include <list>
#include <map>
#include <algorithm>

class People {
public:
    People(const std::string& nameInDictionary) :
        name(nameInDictionary)
    {
    }

    const std::string& GetName() const
    {
        return name;
    }

private:
    const std::string& name;
};

class NameDictionary {
public:
    NameDictionary()
    {
    }

    const std::string& SetAndGetName(const std::string& name)
    {
        std::list<std::string>::iterator found = std::find(allNames.begin(), allNames.end(), name);
        if (found == allNames.end())
        {
            allNames.push_back(name);
            found = std::find(allNames.begin(), allNames.end(), name);
        }

        return *found;
    }

private:
    std::list<std::string> allNames;
};

class PeopleList {
public:
    PeopleList()
    {
    }

    ~PeopleList()
    {
        CleanList();
    }

    void Add(const std::string& name)
    {
        const std::string& nameInDictionary = dictionary.SetAndGetName(name);
        peopleList.push_back(new People(nameInDictionary));
    }

    const std::list<People*>& GetList() const
    {
        return peopleList;
    }

private:
    NameDictionary dictionary;
    std::list<People*> peopleList;

private:
    void CleanList()
    {
        std::list<People*>::const_iterator it = peopleList.begin();
        std::list<People*>::const_iterator itEnd = peopleList.end();

        while (it != itEnd)
        {
            delete *it;
            ++it;
        }
    }
};

class NameCounter {
public:
    NameCounter(const PeopleList& peopleList)
    {
        const std::list<People*> internalList = peopleList.GetList();
        std::list<People*>::const_iterator it = internalList.begin();
        std::list<People*>::const_iterator itEnd = internalList.end();

        while (it != itEnd)
        {
            People* people = *it;

            if (count.count(people->GetName()))
            {
                count.insert(std::pair<std::string, int>(people->GetName(), 0));
            }

            ++count[people->GetName()];
            ++it;
        }
    }

    void Print()
    {
        std::map<std::string, int>::const_iterator it = count.begin();
        std::map<std::string, int>::const_iterator itEnd = count.end();

        while (it != itEnd)
        {
            const std::pair<const std::string, int>& entry = *it;

            std::cout << "Name: " << entry.first << " with count: " << entry.second << std::endl;
            ++it;
        }
    }

private:
    std::map<std::string, int> count;
};

int main(int argc, const char* argv[])
{
    PeopleList peopleList;

    peopleList.Add("Pierre");
    peopleList.Add("Paul");
    peopleList.Add("Jean");
    peopleList.Add("Pierre");

    for (int i = 0; i < 1000; ++i)
    {
        peopleList.Add("Luc");

        NameCounter counter(peopleList);
        counter.Print();
    }

    return 0;
}

Changeons les calculs

Dans cette nouvelle étape, je m'attaque au NameCounter. Celui-ci a en effet un gros problème : à chaque fois que l'on veut obtenir le nombre d'occurrences de chaque prénom, l'objet refait les calculs et en profite pour allouer une structure gourmande en mémoire (le std::map). L'avantage est que cette mémoire n'est utilisée que lors du besoin de comptage. L'inconvénient est que cela prend vraiment beaucoup de mémoire et de temps.

L'idée est donc de garder la statistique qui nous intéresse quelque part et de la calculer au fur et à mesure de l'ajout de personnes à l'instance de PeopleList. C'est facile, car il n'y a qu'un seul moyen d'ajouter des personnes. Défense cependant d'ajouter le comptage directement à PeopleList, je vais utiliser un objet spécialisé dans ce comptage.

Il y a un autre choix à faire : est-ce que le nombre d'occurrence est à stocker dans le dictionnaire de noms ou pas. Je choisi que non. Au final, la mémoire utilisée sera légèrement plus importante, mais on conserve une flexibilité intéressante : pas besoin de changer le dictionnaire lorsque l'on ne veut pas compter les occurrences.

La classe ajoutée est NameUsageCounter. À chaque ajout de personne à la liste, on lui signal le prénom utilisé. L'objet enregistre une référence vers le dictionnaire et augmente une compteur.

L'usage de cette classe permet de se passer du GetList() de PeopleList qui cassait l'encapsulation en le remplaçant par GetCounter(). On pourra objecter que l'on expose toujours des détails internes. Dans une certaine mesure, oui, et on pourrait pousser le design pour éviter cela, mais il faut mettre une limite à cet article.

NameCounter profite grandement de NameUsageCounter et se contente d'aller y piocher les informations à afficher.

C'est beaucoup plus rapide, et ça prend beaucoup moins de place (un peak de moins de 40ko).

NameUsageCounter utiliser un std::vector en interne. Là encore, ce n'est pas une structure bien stable en mémoire, même si pour le faible nombre de prénoms de mon exemple, cela a peu d'influence. Connaître le nombre maximum de prénom permettrait une structure plus fixe et plus gentille avec les accès mémoire.

#include <iostream>
#include <string>
#include <list>
#include <map>
#include <vector>
#include <algorithm>

class People {
public:
    People(const std::string& nameInDictionary) :
        name(nameInDictionary)
    {
    }

    const std::string& GetName() const
    {
        return name;
    }

private:
    const std::string& name;
};

class NameDictionary {
public:
    NameDictionary()
    {
    }

    const std::string& SetAndGetName(const std::string& name)
    {
        std::list<std::string>::iterator found = std::find(allNames.begin(), allNames.end(), name);
        if (found == allNames.end())
        {
            allNames.push_back(name);
            found = std::find(allNames.begin(), allNames.end(), name);
        }

        return *found;
    }

private:
    std::list<std::string> allNames;
};

class NameUsageCounter {
public:
    struct Entry {
        Entry(const std::string& nameForEntry) :
            name(&nameForEntry),
            count(0)
        {
        }

        const std::string* name;
        int count;
    };

    typedef std::vector<Entry> EntryCollection;

public:
    NameUsageCounter()
    {
    }

    void AddUsage(const std::string& name)
    {
        int* counter = FindCounter(name);
        if (!counter)
        {
            counter = CreateCounter(name);
        }

        ++(*counter);
    }

    const std::vector<Entry>& GetEntries() const
    {
        return counters;
    }

private:
    EntryCollection counters;

private:
    int* FindCounter(const std::string& name)
    {
        int counterCount = counters.size();
        for (int index = 0; index < counterCount; ++index)
        {
            if (counters[index].name == &name)
            {
                return &counters[index].count;
            }
        }
        return NULL;
    }

    int* CreateCounter(const std::string& name)
    {
        counters.push_back(Entry(name));
        return &(counters.back().count);
    }
};

std::ostream& operator<<(std::ostream& out, const NameUsageCounter::Entry& entry)
{
    out << "Name: " << *entry.name << " with count: " << entry.count;
    return out;
}

class PeopleList {
public:
    PeopleList()
    {
    }

    ~PeopleList()
    {
        CleanList();
    }

    void Add(const std::string& name)
    {
        const std::string& nameInDictionary = dictionary.SetAndGetName(name);
        peopleList.push_back(new People(nameInDictionary));
        counter.AddUsage(nameInDictionary);
    }

    const NameUsageCounter& GetCounter() const
    {
        return counter;
    }

private:
    NameDictionary dictionary;
    NameUsageCounter counter;
    std::list<People*> peopleList;

private:
    void CleanList()
    {
        std::list<People*>::const_iterator it = peopleList.begin();
        std::list<People*>::const_iterator itEnd = peopleList.end();
        while (it != itEnd)
        {
            delete* it;
            ++it;
        }
    }
};

class NameCounter {
public:
    NameCounter(const PeopleList& peopleList) :
        counter(peopleList.GetCounter())
    {
    }

    void Print()
    {
        NameUsageCounter::EntryCollection::const_iterator it = counter.GetEntries().begin();
        NameUsageCounter::EntryCollection::const_iterator itEnd = counter.GetEntries().end();
        while (it != itEnd)
        {
            std::cout << *it;
            ++it;
        }
    }

private:
    const NameUsageCounter& counter;
};

int main(int argc, const char* argv[])
{
    PeopleList peopleList;

    peopleList.Add("Pierre");
    peopleList.Add("Paul");
    peopleList.Add("Jean");
    peopleList.Add("Pierre");

    for (int i = 0; i < 1000; ++i)
    {
        peopleList.Add("Luc");

        NameCounter counter(peopleList);
        counter.Print();
    }

    return 0;
}

Une dernière modification mémoire

Le programme est donc bien « orienté objet », il y a même plus de classes qu'au début, avec une encapsulation correcte sans être académiquement parfaite.

Je donne un dernier petit exemple de modification de structure de données pour montrer à quel point l'architecture orientée données est orthogonale, et même facilitée, par une programmation objet.

Imaginons que lors de notre profiling, nous découvrions que l'indirection dans NameUsageCounter::Entry pour afficher le nom soit coûteuse (le programme n'est pas assez complexe et la plupart des performances sont de toute façons impactées par l'utilisation de std::string et de std::ostream...). Nous aimerions avoir dans Entry une copie de la donnée prénom.

Voyons comme les modifications sont minimes. Une taille maximum de chaine est nécessaire pour que Entry puisse être accédée directement. Entry gagne un tableau de caractères local. NameUsageEntry gagne un std::vector de références vers les entrées du dictionnaire nécessaire à FindCounter().

Et voilà, Entry est prête pour des traitements de longs tableaux contigus en mémoire. Seul du code de NameUsageCounter (et l'operator \<\< associé) a du être modifié.

#include <iostream>
#include <string>
#include <cstring>
#include <list>
#include <map>
#include <vector>
#include <algorithm>

class People {
public:
    People(const std::string& nameInDictionary) :
        name(nameInDictionary)
    {
    }

    const std::string& GetName() const
    {
        return name;
    }

private:
    const std::string& name;
};

class NameDictionary {
public:
    NameDictionary()
    {
    }

    const std::string& SetAndGetName(const std::string& name)
    {
        std::list<std::string>::iterator found = std::find(allNames.begin(), allNames.end(), name);
        if (found == allNames.end())
        {
            allNames.push_back(name);
            found = std::find(allNames.begin(), allNames.end(), name);
        }

        return *found;
    }

private:
    std::list<std::string> allNames;
};

const int MAX_NAME_SIZE = 7;

class NameUsageCounter {
public:
    struct Entry {
        Entry(const std::string& nameForEntry) :
            count(0)
        {
            std::strncpy(localName, nameForEntry.c_str(), MAX_NAME_SIZE);
            localName[MAX_NAME_SIZE] = '\0';
        }

        char localName[MAX_NAME_SIZE + 1];
        int count;
    };

    typedef std::vector<Entry> EntryCollection;

public:
    NameUsageCounter()
    {
    }

    void AddUsage(const std::string& name)
    {
        int* counter = FindCounter(name);
        if (!counter)
        {
            counter = CreateCounter(name);
        }

        ++(*counter);
    }

    const std::vector<Entry>& GetEntries() const
    {
        return counters;
    }

private:
    EntryCollection counters;
    std::vector<const std::string*> names;

private:
    int* FindCounter(const std::string& name)
    {
        int counterCount = counters.size();
        for (int index = 0; index < counterCount; ++index)
        {
            if (names[index] == &name)
            {
                return &counters[index].count;
            }
        }
        return nullptr;
    }

    int* CreateCounter(const std::string& name)
    {
        counters.push_back(Entry(name));
        names.push_back(&name);
        return &(counters.back().count);
    }
};

std::ostream& operator<<(std::ostream& out, const NameUsageCounter::Entry& entry)
{
    out << "Name: " << entry.localName << " with count: " << entry.count;
    return out;
}

class PeopleList {
public:
    PeopleList()
    {
    }

    ~PeopleList()
    {
        CleanList();
    }

    void Add(const std::string& name)
    {
        const std::string& nameInDictionary = dictionary.SetAndGetName(name);
        peopleList.push_back(new People(nameInDictionary));
        counter.AddUsage(nameInDictionary);
    }

    const NameUsageCounter& GetCounter() const
    {
        return counter;
    }

private:
    NameDictionary dictionary;
    NameUsageCounter counter;
    std::list<People*> peopleList;

private:
    void CleanList()
    {
        std::list<People*>::const_iterator it = peopleList.begin();
        std::list<People*>::const_iterator itEnd = peopleList.end();
        while (it != itEnd)
        {
            delete* it;
            ++it;
        }
    }
};

class NameCounter {
public:
    NameCounter(const PeopleList& peopleList) :
        counter(peopleList.GetCounter())
    {
    }

    void Print()
    {
        NameUsageCounter::EntryCollection::const_iterator it = counter.GetEntries().begin();
        NameUsageCounter::EntryCollection::const_iterator itEnd = counter.GetEntries().end();
        while (it != itEnd)
        {
            std::cout << *it;
            ++it;
        }
    }

private:
    const NameUsageCounter& counter;
};

int main(int argc, const char* argv[])
{
    PeopleList peopleList;

    peopleList.Add("Pierre");
    peopleList.Add("Paul");
    peopleList.Add("Jean");
    peopleList.Add("Pierre");

    for (int i = 0; i < 1000; ++i)
    {
        peopleList.Add("Luc");

        NameCounter counter(peopleList);
        counter.Print();
    }

    return 0;
}

Et donc ?

J'espère avoir montré, malheureusement sur un petit exemple seulement, que la programmation orientée objet et l'architecture orientée données ne se marchent pas sur les pieds, mais concourent ensemble pour le meilleur.

Je n'ai pas traité le cas de classes virtuelles, cela m'aurait pris quelques heures de plus pour finalement pas grand chose : vous avez du comprendre que l'on pourrait avoir plusieurs types de People virtuels qui pourraient être utilisés comme tels, tout en utilisant des structures de données internes calibrées pour les traitements nécessaires au programme.

Oui, il y a des pièges à la POO. Tout outil mal utilisé peut présenter des pièges.

La présentation dont il est question initialement montre comment éviter l'un des pièges, mais il ne montre pas qu'il faut murer l'entrée amenant à ce piège.