CodeGym /Cours /C++ SELF /Mini‑hygiène des performances comme partie du triage

Mini‑hygiène des performances comme partie du triage

C++ SELF
Niveau 47, Leçon 3
Disponible

1. La mini‑hygiène des performances aide le triage

Quand on entend le mot «performance», l'esprit d'un débutant imagine souvent la scène : quelqu'un en col roulé noir optimise un tri à bulles pour qu'il devienne «presque rapide». Dans l'enquête sur les erreurs mémoire, c'est plus terre‑à‑terre : la performance est importante dans la mesure où elle affecte la stabilité du comportement. Plus il y a de copies et de déplacements aléatoires en mémoire, plus il y a de «bruit» autour des bugs, et plus il est difficile de distinguer la cause première de l'effet.

Imaginez que vous avez un bug lié au lifetime : vous avez quelque part sauvegardé un pointeur vers un élément de std::vector, puis appelé push_back et obtenu une reallocation. Si la reallocation survient «parfois» (selon la capacity actuelle), le bug se manifestera «parfois». Si, pendant l'enquête, vous appelez reserve, le déplacement peut disparaître — et vous simplifierez soit le traçage, soit, au contraire, vous comprendrez que le problème ne vient pas de la reallocation mais d'une autre partie du code. Dans les deux cas vous gagnez : le signal devient maîtrisable.

Il est important de garder en tête le principe clé du jour : d'abord la correction, puis l'optimisation. Nous n'allons pas «tuner» le programme pour la vitesse. Nous allons faire de petits changements qui simultanément (a) accélèrent généralement le code, (b) réduisent le nombre d'événements aléatoires (copies, déplacements), et ainsi (c) simplifient l'enquête et rendent les rapports des sanitizers plus directs.

2. std::vector : reserve, reallocation et différence avec resize

std::vector est le cheval de bataille des conteneurs, mais il a une caractéristique : il stocke les éléments dans un bloc de mémoire contiguë. Tant qu'il y a de la place — tout va bien. Quand il n'y a plus de place, le vector alloue un nouveau buffer (généralement plus grand), déplace/recopie les éléments puis libère l'ancien buffer. C'est la reallocation. Après cela, tous les pointeurs/références/itérateurs/std::span vers les éléments de l'ancien buffer deviennent invalides.

Voici un schéma simple de ce qui se passe :

flowchart LR
    A[vector buffer #1
capacity=3] -->|push_back 4th| B[allocate buffer #2
capacity=6] B --> C[move/copy elements] C --> D[free buffer #1]

Dans l'enquête de bugs le mot clé est ici — «parfois». Parfois la capacity suffit, parfois non. Parfois push_back déclenche un déplacement, parfois non. Ainsi un même pointeur invalide peut «par hasard» encore pointer vers des données, ou pointer vers nulle part. Le sanitizer ressemble alors à un détective arrivé sur la scène du crime une semaine après : il reste des traces, mais une partie a été effacée par la pluie.

C'est là que reserve devient moins une «optimisation» qu'un stabilisateur d'expérience. Si vous connaissez approximativement la limite supérieure de la taille, vous pouvez demander au vector d'allouer suffisamment de mémoire à l'avance et ainsi réduire le nombre de déplacements.

Un mini‑exemple où reserve rend le comportement plus prévisible :

#include <vector>
#include <string>

int main() {
    std::vector<std::string> lines;
    lines.reserve(1000); // moins de reallocations -> moins de déplacements aléatoires

    lines.push_back("one");
    lines.push_back("two");
}

Notez un détail pratique : dans la parole et le texte il est facile de dire «reserve()», mais dans le code réel on appelle reserve(size_type), c'est‑à‑dire que l'on fournit toujours une taille concrète. En débogage c'est utile de le garder à l'esprit littéralement : «on réserve tel nombre».

reserve et resize — des mots proches, des conséquences différentes

Ces deux méthodes sont souvent confondues parce qu'elles semblent parler de la taille. Mais conceptuellement elles viennent de mondes différents : reserve concerne la capacité (capacity), tandis que resize concerne la taille logique (size) et même la création/suppression d'éléments. En triage c'est critique : se tromper signifie obtenir des éléments supplémentaires, des valeurs inattendues, parfois même des branches de logique différentes, et vous voilà en train d'enquêter sur un bug qui n'était pas le bon.

Formulons en termes humains. reserve(n) dit : «cher vector, prépare au moins de la place pour n éléments, mais ne change pas ce que je considère comme les éléments réellement stockés». resize(n) dit : «cher vector, tu dois maintenant avoir réellement n éléments ; si tu en avais moins, crée‑en, si tu en avais plus, supprime les excédents».

Mini‑démonstration dans un contexte d'apprentissage (on lit conditionnellement les commandes utilisateur et on stocke l'historique des lignes) :

#include <iostream>
#include <vector>
#include <string>

int main() {
    std::vector<std::string> history;
    history.reserve(3);

    std::cout << history.size() << '\n'; // 0
    history.push_back("add task");
    std::cout << history.size() << '\n'; // 1
}

Et maintenant la même chose avec resize, pour sentir la différence :

#include <iostream>
#include <vector>
#include <string>

int main() {
    std::vector<std::string> history;
    history.resize(3); // la size est devenue 3, 3 chaînes vides ont été ajoutées

    std::cout << history.size() << '\n'; // 3
    std::cout << '"' << history[0] << '"' << '\n'; // ""
}

En triage c'est particulièrement important : si, «pour optimiser», vous avez accidentellement inséré resize, vous avez changé le comportement du programme. L'enquête devrait se limiter à des changements minimaux. Retenez donc : reserve — généralement un «stabilisateur» sûr, resize — c'est déjà une modification des données et de la logique.

3. Moins de copies et signatures honnêtes

La copie en C++ est sournoise. Un débutant la considère souvent comme «après tout, j'ai juste passé une chaîne». Puis il découvre que la chaîne contient un buffer sur le heap, que copier implique une allocation, un transfert de données, des réallocations possibles. Même si tout est correct, cela crée un «bruit de fond» : plus d'allocations, plus de mouvement en mémoire, plus d'endroits où un bug de lifetime peut se manifester ou être masqué.

Du point de vue de l'enquête sur les erreurs mémoire, les copies superflues nuisent de deux façons. D'abord, elles altèrent la «carte» des allocations : le sanitizer affiche la trace des allocations/libérations, et vous voyez plus d'événements que nécessaire. Ensuite, les copies peuvent prolonger accidentellement la vie des données (par exemple, on a fait une copie d'une string — et soudain std::string_view «fonctionne», alors qu'il pointait sur un objet tempora). C'est particulièrement pénible : le bug devient «non reproductible», car vous l'avez accidentellement couvert par une copie.

La mini‑hygiène ici est très simple : ne passer par valeur que lorsque nous avons réellement besoin d'une copie ou de la possession, et dans tous les autres cas — passer par const& ou utiliser des types view (si leur lifetime le permet).

Un petit tableau indicatif (sans fanatisme, nous n'écrivons pas une thèse) :

Ce qu'on passe Type du paramètre Quand c'est approprié À retenir
Petit et bon marché (int, bool)
int x
Presque toujours Simple et clair
Grand objet possédant (std::string, std::vector)
const T&
Lecture sans copie Les données doivent vivre chez l'appelant
Chaîne «pour le temps du traitement»
std::string_view
Parsing/vérifications à l'intérieur de l'appel Ne pas sauvegarder le view pour plus tard
Données contiguës
std::span<const T>
Traitement d'un tableau/vector sans copies La source ne doit pas déménager

Signatures honnêtes sur l'exemple d'un gestionnaire de tâches en console

Pour ancrer le sujet, l'attachons à une application : un simple «gestionnaire de tâches» en console. On a le modèle :

#include <string>

struct Task {
    int id = 0;
    std::string text;
    bool done = false;
};

Et le stockage :

#include <vector>

struct TaskStore {
    std::vector<Task> tasks;
};

Regardons maintenant les signatures des fonctions. Supposons que nous voulons ajouter une tâche. La version naïve ressemble souvent à ceci :

#include <string>
#include <vector>

void add_task(std::vector<Task>& tasks, std::string text) {
    tasks.push_back(Task{static_cast<int>(tasks.size()) + 1, text, false});
}

Ici text est passé par valeur, donc copié à l'appel. Parfois c'est acceptable, mais souvent on préfère accepter «quelque chose qui ressemble à une string» sans copies inutiles, puis en faire la copie dans le stockage parce que la tâche doit posséder son texte.

Une variante soignée pour notre cas :

#include <string>
#include <string_view>
#include <vector>

void add_task(std::vector<Task>& tasks, std::string_view text) {
    Task t;
    t.id = static_cast<int>(tasks.size()) + 1;
    t.text = std::string{text}; // on fait exactement une copie : dans le stockage
    t.done = false;
    tasks.push_back(t);
}

Le sens est subtil mais très pratique : la fonction ne force pas l'appelant à construire une std::string. Elle peut accepter une std::string, un littéral, ou un morceau de chaîne. Mais dans Task nous enregistrons une chaîne possédante, pas une std::string_view, pour éviter un dangling.

De même pour l'affichage. Si on imprime les tâches, passer le conteneur par valeur est une copie inutile (et de la mémoire, et des déplacements) :

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const Task& t : tasks) {
        std::cout << t.id << ". " << t.text << '\n';
    }
}

Ce n'est pas une «super‑optimisation». C'est de l'honnêteté de base : la fonction d'affichage ne doit pas posséder la liste des tâches, elle doit seulement la lire.

string_view et span — des view pratiques mais avec un lifetime strict

Les view sont comme regarder de plus près des données sans les emporter. Pratique, rapide, mais si vous les emportez (sauvegardez un view dans un champ de structure, retournez une référence vers une locale), vous obtenez le classique «ça marchait hier».

En triage, les view réduisent le nombre de copies, donc le nombre d'allocations et de déplacements. Mais cela exige de la discipline sur les durées de vie.

Considérons une fonction typique de parsing d'une commande utilisateur. On lit une ligne line puis on veut vérifier si elle commence par "add " :

#include <string_view>

bool starts_with_add(std::string_view s) {
    return s.starts_with("add ");
}

Utilisation :

#include <iostream>
#include <string>

int main() {
    std::string line = "add buy milk";
    std::cout << std::boolalpha << starts_with_add(line) << '\n'; // true
}

Ici tout est sûr : line vit plus longtemps que l'appel. Mais le danger commence quand on tente de retourner une std::string_view qui référence une string locale :

#include <string>
#include <string_view>

std::string_view bad() {
    std::string tmp = "add buy milk";
    return tmp; // tmp meurt -> le view devient pendu
}

Ce piège a déjà été abordé dans la thématique des erreurs de lifetime, et ici il est important en tant que «hygiène» : parfois une copie supplémentaire cache le problème, tandis qu'une signature honnête aide à le révéler. Règle simple : std::string_view et std::span sont utiles comme paramètres, mais dangereux comme données «stockées» sans garanties sur le propriétaire.

reserve plus moins de copies : stabilisation sur un fragment de code en vie

Rassemblons un petit fragment d'application illustrant les deux idées : on lit des commandes, on ajoute des tâches, on affiche parfois la liste. Pour l'enquête il est important que le vector de tâches ne bouge pas à chaque deux opérations, et que les chaînes ne soient pas copiées trois fois «par hasard».

Un squelette minimal (sans architecture complexe, juste le principe) :

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.reserve(100); // on stabilise les adresses des éléments pendant le débogage

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "list") {
            print_tasks(tasks);
        } else if (line.starts_with("add ")) {
            add_task(tasks, std::string_view(line).substr(4));
        }
    }
}

Ici, add_task prend un std::string_view, et on lui passe un view sur la sous‑chaîne de line. C'est sûr parce que add_task fait à l'intérieur une std::string à partir de text et la place dans Task. Autrement dit, on ne stocke pas le view, on l'utilise «le temps de».

Et tasks.reserve(100) n'est pas une promesse «nous aurons exactement 100 tâches», c'est un mouvement pratique : «pendant l'enquête, évitons que std::vector saute trop souvent en mémoire». Quand le bug est trouvé et corrigé, la valeur peut être revue ou supprimée si elle n'a plus de sens.

Je souligne : reserve n'offre pas une garantie absolue «jamais de déplacement». Si on atteint 101 tâches — le vector déplacera. Mais en triage c'est souvent suffisant : rendre le comportement «maîtrisable». On peut mettre reserve(1000) pour tester une hypothèse, ou l'enlever pour voir si le symptôme change.

4. Erreurs typiques

Erreur n°1 : transformer reserve en talisman magique «contre tous les bugs».
Parfois, après quelques résultats positifs, on se dit : «J'ai ajouté reserve et tout a cessé de planter — donc c'est réparé». Non. reserve peut supprimer la manifestation du use‑after‑realloc, mais la cause première (stocker un pointeur/référence vers un élément de std::vector) reste. Le bug devient juste plus sournois : il attendra que le volume de données augmente.

Erreur n°2 : confondre reserve et resize, puis enquêter sur un nouveau problème.
Si vous vouliez réduire le nombre de reallocations mais avez accidentellement fait resize, vous avez modifié le contenu du conteneur. Résultat : des éléments «vides» apparaissent, la logique change, et vous corrigez des symptômes qui n'étaient pas initiaux. En triage c'est particulièrement dommageable car vous perdez le lien avec le bug d'origine.

Erreur n°3 : «optimiser» les signatures en stockant std::string_view dans le modèle.
Scénario fréquent : un étudiant voit que std::string_view évite les copies et décide de le conserver dans Task pour «ne jamais copier les chaînes». Puis il s'avère que la chaîne source était temporaire ou réutilisée, et toutes les tâches pointent soudain vers le même buffer. Le modèle de données doit presque toujours posséder ce dont il a besoin pour vivre.

Erreur n°4 : passer de gros objets par valeur «pour la simplicité», puis s'étonner de l'instabilité.
Passer std::vector<Task> ou std::string par valeur n'est pas toujours mal, mais si la fonction ne doit pas posséder les données, cela ajoute des copies invisibles. Ces copies créent des allocations et des déplacements inutiles, et dans le diagnostic les événements en trop peuvent brouiller la vue : le rapport ASan devient plus long et le comportement moins reproductible.

Erreur n°5 : utiliser des view là où il faut un résultat possédant, et essayer de «réparer» cela avec reserve.
Parfois on se dit : «Je retournerai un std::string_view, c'est plus rapide». Mais si la valeur retournée doit vivre indépendamment d'une string locale temporaire, le view est conceptuellement faux ici. reserve ne sauvera pas la situation : il s'applique aux conteneurs, pas aux durées de vie des variables locales. La solution correcte est souvent simple : retourner une std::string par valeur et dormir tranquille.

1
Mission
C++ SELF, niveau 47, leçon 3
Bloqué
Tableau des carrés
Tableau des carrés
1
Mission
C++ SELF, niveau 47, leçon 3
Bloqué
Concaténation de phrases
Concaténation de phrases
1
Mission
C++ SELF, niveau 47, leçon 3
Bloqué
Filtre de lignes
Filtre de lignes
1
Mission
C++ SELF, niveau 47, leçon 3
Bloqué
Console de tâches
Console de tâches
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION