Aller au contenu
Topos Uranos

Résumé

Un std::array exige de connaître à la compilation combien d’éléments il aura ; la plupart des problèmes, en revanche, ne le disent qu’à l’arrivée des données. Cette leçon présente std::vector, la séquence qui grandit selon les données : comment on la crée, comment on lui ajoute des éléments avec push_back et comment on lit avec elle des séries de longueur inconnue. Suivent les trois parcours canoniques de toute programmation avec des collections (accumuler, filtrer et transformer), avec une boucle et avec les algorithmes de la bibliothèque ; le passage d’un vecteur à une fonction, et pourquoi renvoyer un vecteur nouveau ne coûte presque rien. Elle se termine par l’intérieur du vecteur : sa taille et sa capacité, le bloc qu’on remplace par un autre deux fois plus grand quand il est plein, et la référence à un élément qui, pour cette raison, peut rester pointée vers de la mémoire libérée.

Objectifs d’apprentissage

  1. Créer un std::vector, lui ajouter des éléments avec push_back et le parcourir, en lisant avec lui une série de données de longueur inconnue.
  2. Écrire des fonctions qui reçoivent un vecteur par référence constante pour le lire et par référence pour le modifier, et qui renvoient un vecteur nouveau quand le résultat est une séquence.
  3. Implémenter des parcours d’accumulation, de filtrage et de transformation, reconnaître dans un problème le schéma qui lui correspond et l’abréger, quand la bibliothèque le permet, avec std::ranges::count, std::ranges::max ou std::ranges::fold_left.
  4. Expliquer avec un exemple la différence entre la taille et la capacité d’un vecteur, et pourquoi une référence à un élément peut être invalidée quand on en ajoute un autre.

La séquence qui grandit

Créer et remplir un vecteur

La leçon Tableaux de taille fixe a présenté la séquence indexée : des éléments du même type, l’un après l’autre, numérotés à partir de zéro. Sa forme moderne de taille fixe, std::array<int, 7>, porte la taille dans le type, et le compilateur doit la connaître. Or, la personne qui écrit un programme calculant la moyenne des notes d’un groupe ne sait pas combien d’élèves il comptera. Il s’ensuit qu’il faut une séquence dont la taille soit une donnée de plus, qui change pendant l’exécution : c’est le vecteur, std::vector<T>, de l’en-tête <vector>, où T est le type des éléments.

Programme en C++
#include <print>#include <vector>​int main(){    std::vector<int> squares{};    std::println("Au début : taille {}, vide : {}", squares.size(), squares.empty());    for (int k{1}; k <= 5; ++k) {        squares.push_back(k * k);    }    std::println("{}", squares);    std::println("Taille {}, premier {}, dernier {}", squares.size(), squares[0], squares.back());    squares[1] = 0;    squares.pop_back();    std::println("{}", squares);}
Sortie
Au début : taille 0, vide : true[1, 4, 9, 16, 25]Taille 5, premier 1, dernier 25[1, 0, 9, 16]

La ligne 6 crée un vecteur vide d’entiers : à la différence du tableau, il n’y a pas à annoncer combien d’éléments il aura. size() renvoie combien il en a, et empty() dit s’il n’en a aucun. La boucle des lignes 8 à 10 ajoute cinq carrés avec push_back, qui place son argument à la fin et augmente la taille de un. La ligne 11 écrit le vecteur entier avec le formatage des ranges que la leçon précédente a utilisé avec les tableaux. L’accès par indice est celui de toujours, et squares.back() abrège squares[squares.size() - 1]. La ligne 13 affecte un élément existant, et la ligne 14 retire le dernier avec pop_back. Les opérations de cette leçon sont, en résumé, les suivantes.

  • v.push_back(x) ajoute x à la fin ; v.pop_back() retire le dernier (avec le vecteur vide, c’est un comportement indéfini).
  • v.size() est la taille, de type std::size_t, l’entier non signé des tailles qu’a présenté la leçon Tableaux de taille fixe ; v.empty() équivaut à v.size() == 0.
  • v[i] et v.at(i) donnent l’élément d’indice i, qui doit vérifier i < v.size() ; v.front() et v.back() donnent le premier et le dernier, et exigent que le vecteur ne soit pas vide.
  • v.resize(n) laisse le vecteur avec n éléments : il retire ceux qui sont en trop à la fin ou ajoute à la fin des éléments initialisés comme avec des accolades vides (des zéros, pour les nombres).

Comme dans les tableaux, v[i] ne garantit aucune vérification, et v.at(i) si : avec un indice 7 et cinq éléments, il arrête le programme avec le message vector::_M_range_check: __n (which is 7) >= this->size() (which is 5), qui nomme l’indice et la taille. Le mécanisme qui produit cet arrêt, une exception, s’étudie dans Programmation en C++ II.

Accolades et parenthèses

Le cours initialise tout avec des accolades, et le vecteur est le premier type du cours où remplacer les accolades par des parenthèses change le résultat. Avec des accolades, les valeurs écrites sont les éléments ; avec des parenthèses, le premier nombre est le nombre d’éléments et le second, s’il y en a un, la valeur de chacun.

Programme en C++
#include <print>#include <vector>​int main(){    const std::vector<int> a{5};    const std::vector<int> b(5);    const std::vector<int> c{3, 7};    const std::vector<int> d(3, 7);    std::println("{} {} {} {}", a, b, c, d);}
Sortie
[5] [0, 0, 0, 0, 0] [3, 7] [7, 7, 7]

a a un élément, le 5 ; b, cinq zéros ; c, le 3 et le 7 ; d, trois sept. La liste entre accolades dit donc « voici les éléments », et les parenthèses sont réservées pour demander n copies d’une même valeur, comme un vecteur de compteurs à zéro.

Le dernier d’un vecteur vide

Un vecteur vide est un cas légitime (l’entrée sans données, le filtre que personne ne franchit), et chaque formule avec sa taille doit être revue dans ce cas ; la plus traîtresse est v.size() - 1. Comme v.size() est un entier non signé, lui soustraire 1 quand il vaut 0 ne donne pas −1-1, mais fait le tour, comme on l’a vu dans la leçon Les entiers et leur représentation, jusqu’à la plus grande valeur de std::size_t.

Programme en C++
#include <cstddef>#include <print>#include <vector>​int main(){    const std::vector<int> values{};    std::println("values.size() - 1 = {}", values.size() - 1);    // Borne erronée : avec le vecteur vide, la condition est vraie.    for (std::size_t i{0}; i <= values.size() - 1; ++i) {        std::println("{}", values[i]);    }}
Sortie
values.size() - 1 = 18446744073709551615/usr/include/c++/15/bits/stl_vector.h:1282: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::const_reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) const [with _Tp = int; _Alloc = std::allocator<int>; const_reference = const int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.

Le système a arrêté le programme avec le signal 6.

La première ligne de la sortie est 18 446 744 073 709 551 61518\,446\,744\,073\,709\,551\,615, c’est-à-dire 264−12^{64} - 1, le plus grand std::size_t de cette plateforme. Il s’ensuit que la condition de la boucle, 0 <= 18446744073709551615, est vraie, et que la première itération lit values[0] dans un vecteur qui n’a pas d’élément 0 : comportement indéfini. Ce que l’on observe dépend des options, comme dans les tableaux : compilée sans optimisation, la bibliothèque de GCC 15 vérifie l’indice de operator[] et arrête le programme par une assertion qui dit __n < this->size() ; avec optimisation, cette vérification disparaît, et la boucle, qui ne s’arrête pas à temps, tente de lire là où il n’y a aucun élément (vérifié sous Linux avec -O2 : comme le vecteur vide n’a pas de bloc, le programme se termine par une erreur de segmentation, sans message qui en explique la cause). La correction consiste à écrire la borne sans soustraction, i < values.size(), qui avec le vecteur vide est fausse dès le début ; et, là où la soustraction semble nécessaire, à interroger d’abord values.empty().