Skip to content

Latest commit

 

History

History
432 lines (310 loc) · 13.9 KB

File metadata and controls

432 lines (310 loc) · 13.9 KB

🔝 Retour au Sommaire

4.1.3 — Range-based for loop

Chapitre 4 · Structures de Contrôle et Fonctions · Niveau Débutant


Le problème avec les boucles classiques

Itérer sur un conteneur en C++ a longtemps été verbeux et sujet aux erreurs. Prenons un simple parcours de std::vector avec les outils hérités du C et du C++ pré-2011 :

#include <iostream>
#include <vector>

int main() {
    std::vector<std::string> fruits = {"pomme", "banane", "cerise"};

    // Style C — index manuel
    for (std::size_t i = 0; i < fruits.size(); ++i) {
        std::cout << fruits[i] << "\n";
    }

    // Style C++98 — itérateurs explicites
    for (std::vector<std::string>::const_iterator it = fruits.begin();
         it != fruits.end(); ++it) {
        std::cout << *it << "\n";
    }
}

Les deux formes fonctionnent, mais elles partagent les mêmes défauts. Le code est bruyant : on manipule des indices ou des itérateurs alors qu'on veut simplement accéder à chaque élément. Les risques d'erreur sont réels : un < au lieu de !=, un mauvais type d'index (int au lieu de std::size_t), un itérateur invalidé. Et surtout, l'intention — « pour chaque fruit, faire quelque chose » — est noyée dans la mécanique d'itération.


La solution : le range-based for (C++11)

C++11 introduit une syntaxe de boucle qui exprime directement le parcours d'une plage d'éléments :

for (declaration : expression) {
    // corps de la boucle
}

L'expression doit être un objet sur lequel on peut appeler begin() et end() (ou pour lequel ces fonctions existent en tant que fonctions libres). La declaration définit la variable qui prend la valeur de chaque élément à chaque itération.

#include <iostream>
#include <vector>

int main() {
    std::vector<std::string> fruits = {"pomme", "banane", "cerise"};

    for (const auto& fruit : fruits) {
        std::cout << fruit << "\n";
    }
}

Le code est plus court, plus lisible, et ne laisse aucune place aux erreurs d'indexation. L'intention est limpide dès la première lecture.


Ce que le compilateur génère réellement

Le range-based for est du sucre syntaxique. Le compilateur le transforme en une boucle avec itérateurs. En simplifiant légèrement, cette boucle :

for (const auto& fruit : fruits) {
    std::cout << fruit << "\n";
}

est équivalente à :

{
    auto&& __range = fruits;
    auto __begin = __range.begin();
    auto __end = __range.end();
    for (; __begin != __end; ++__begin) {
        const auto& fruit = *__begin;
        std::cout << fruit << "\n";
    }
}

Comprendre cette transformation aide à anticiper le comportement dans les cas limites (invalidation d'itérateurs, modification du conteneur pendant le parcours, etc.).


Les formes de déclaration

Le choix du type dans la déclaration détermine si l'élément est copié, référencé, ou accessible en lecture seule. Ce choix a un impact direct sur la performance et sur ce qu'il est possible de faire dans le corps de la boucle.

Par valeur : copie de chaque élément

for (auto fruit : fruits) {
    fruit += " mûr(e)";  // modifie la copie locale, pas le vecteur
    std::cout << fruit << "\n";
}
// Le vecteur 'fruits' est inchangé

Chaque itération crée une copie de l'élément. C'est acceptable pour des types légers (int, double, char), mais coûteux pour des objets plus lourds comme std::string ou des structures complexes.

Par référence : modification en place

std::vector<int> scores = {72, 85, 91, 68};

for (auto& score : scores) {
    score += 5;  // modifie directement l'élément du vecteur
}
// scores == {77, 90, 96, 73}

Aucune copie n'est effectuée. La variable score est un alias vers l'élément réel du conteneur. Toute modification est répercutée.

Par référence constante : lecture sans copie (forme recommandée par défaut)

for (const auto& fruit : fruits) {
    std::cout << fruit << "\n";
    // fruit += "!";  // ❌ Erreur de compilation — const
}

C'est la forme la plus courante et la plus sûre pour un parcours en lecture seule. Elle combine l'absence de copie (performance) et l'impossibilité de modifier l'élément (sécurité).

Résumé des formes

Déclaration Copie ? Modifiable ? Usage typique
auto x Oui Copie locale uniquement Types primitifs, besoin d'une copie indépendante
auto& x Non Oui (modifie le conteneur) Transformation en place
const auto& x Non Non Parcours en lecture seule (cas par défaut)

💡 Règle pratique — Utilisez const auto& par défaut. Passez à auto& uniquement quand vous devez modifier les éléments. Utilisez auto (par valeur) uniquement pour les types primitifs ou quand vous avez besoin d'une copie indépendante.


Conteneurs et types compatibles

Le range-based for fonctionne avec tout objet qui fournit une paire begin() / end() retournant des itérateurs. En pratique, cela couvre la quasi-totalité des cas courants.

Conteneurs de la STL

Tous les conteneurs standard sont compatibles : std::vector, std::array, std::list, std::deque, std::set, std::map, std::unordered_map, etc.

#include <map>
#include <iostream>

int main() {
    std::map<std::string, int> ages = {
        {"Alice", 30},
        {"Bob", 25},
        {"Charlie", 35}
    };

    for (const auto& [nom, age] : ages) {  // Structured binding (C++17)
        std::cout << nom << " a " << age << " ans\n";
    }
}

L'exemple ci-dessus utilise les structured bindings (C++17, section 12.1) pour décomposer chaque std::pair<const std::string, int> en deux variables nommées. Sans C++17, on écrirait pair.first et pair.second :

// C++11/14 — sans structured bindings
for (const auto& pair : ages) {
    std::cout << pair.first << " a " << pair.second << " ans\n";
}

Tableaux C natifs

Les tableaux de taille fixe déclarés sur la stack sont pris en charge. Le compilateur connaît leur taille à la compilation et peut générer les bornes automatiquement.

int values[] = {10, 20, 30, 40, 50};

for (int v : values) {
    std::cout << v << "\n";
}

En revanche, un pointeur vers un tableau (typiquement reçu en paramètre de fonction) ne fonctionne pas : le compilateur a perdu l'information de taille. C'est une des raisons pour lesquelles std::span (C++20, section 13.5) existe.

void process(int* data, std::size_t size) {
    // ❌ Erreur — 'data' est un pointeur, pas un tableau
    // for (int v : data) { ... }

    // ✅ Avec std::span (C++20)
    // for (int v : std::span(data, size)) { ... }

    // ✅ Boucle classique en attendant
    for (std::size_t i = 0; i < size; ++i) {
        std::cout << data[i] << "\n";
    }
}

std::initializer_list

On peut itérer sur une liste d'initialisation temporaire, ce qui est pratique pour des boucles ponctuelles sur un ensemble de valeurs connues :

for (auto x : {1, 2, 3, 5, 8, 13}) {
    std::cout << x << " ";
}
// Sortie : 1 2 3 5 8 13

Chaînes de caractères

std::string expose begin() et end(), ce qui permet d'itérer caractère par caractère :

std::string mot = "Bonjour";

for (char c : mot) {
    std::cout << c << " ";
}
// Sortie : B o n j o u r

Rendre un type personnalisé compatible

Pour qu'un type personnalisé fonctionne avec le range-based for, il suffit de fournir des fonctions begin() et end() — soit comme méthodes membres, soit comme fonctions libres. Les « itérateurs » retournés doivent supporter au minimum trois opérations : la déréférence (*), l'incrémentation préfixe (++), et la comparaison d'inégalité (!=).

#include <iostream>

class IntRange {
    int start_;
    int end_;

public:
    IntRange(int start, int end) : start_(start), end_(end) {}

    struct Iterator {
        int current;
        int  operator*() const { return current; }
        Iterator& operator++() { ++current; return *this; }
        bool operator!=(const Iterator& other) const {
            return current != other.current;
        }
    };

    Iterator begin() const { return {start_}; }
    Iterator end()   const { return {end_}; }
};

int main() {
    for (int n : IntRange(1, 6)) {
        std::cout << n << " ";
    }
    // Sortie : 1 2 3 4 5
}

Ce pattern est la base des views et des ranges (C++20, section 15.6), qui généralisent ce concept en pipelines composables.


Boucles classiques : quand les utiliser encore

Le range-based for est la forme recommandée par défaut, mais certaines situations requièrent toujours les boucles classiques.

Besoin d'un index

std::vector<std::string> lignes = read_file("data.txt");

// L'index est nécessaire pour numéroter les lignes
for (std::size_t i = 0; i < lignes.size(); ++i) {
    std::cout << (i + 1) << ": " << lignes[i] << "\n";
}

💡 En C++23, std::views::enumerate permet d'obtenir un index avec un range-based for : for (auto [i, val] : std::views::enumerate(vec)). Avant cela, la boucle classique avec index reste la solution la plus claire.

Pas d'itération personnalisé ou itération inversée

// Parcours inverse
for (auto it = vec.rbegin(); it != vec.rend(); ++it) {
    std::cout << *it << "\n";
}

// Parcours avec un pas de 2
for (std::size_t i = 0; i < vec.size(); i += 2) {
    std::cout << vec[i] << "\n";
}

C++20 propose std::views::reverse pour le parcours inverse dans un range-based for, mais les pas personnalisés nécessitent toujours une boucle classique ou std::views::stride (C++23).

Suppression d'éléments pendant l'itération

Modifier la taille d'un conteneur pendant un range-based for provoque un comportement indéfini (les itérateurs sont invalidés). Pour supprimer des éléments, on utilise l'idiome erase-remove ou une boucle avec itérateur explicite :

// ✅ Idiome erase-remove (pas de boucle manuelle)
vec.erase(
    std::remove_if(vec.begin(), vec.end(),
                   [](int x) { return x < 0; }),
    vec.end()
);

// ✅ En C++20 : std::erase_if (encore plus simple)
std::erase_if(vec, [](int x) { return x < 0; });

Boucles while et do-while

Le range-based for ne couvre pas les cas où la condition d'arrêt n'est pas liée à un conteneur — lecture de flux, boucle événementielle, attente d'une condition externe :

// Lecture ligne par ligne depuis stdin
std::string line;  
while (std::getline(std::cin, line)) {  
    process(line);
}

// Boucle événementielle
do {
    event = poll_event();
    handle(event);
} while (event.type != EventType::Quit);

Pièges courants

Itérer sur une référence temporaire dangereuse

Itérer directement sur un temporaire simple est sûr — le compilateur prolonge sa durée de vie jusqu'à la fin de la boucle :

// ✅ Sûr — le temporaire vit jusqu'à la fin du for
for (auto& c : get_string_by_value()) {
    std::cout << c;
}

Le vrai piège survient quand l'expression range contient un temporaire intermédiaire dont la durée de vie n'est pas prolongée :

// ❌ Comportement indéfini (avant C++23)
// foo() crée un temporaire, .get_items() retourne une référence vers ses données,
// puis le temporaire est détruit avant la première itération
for (auto& item : foo().get_items()) {
    process(item);
}

Avant C++23, la solution est de stocker le résultat intermédiaire dans une variable nommée :

// ✅ Sûr dans toutes les versions
auto obj = foo();  
for (auto& item : obj.get_items()) {  
    process(item);
}

C++23 (P2718R0) a corrigé ce problème en prolongeant la durée de vie de tous les temporaires de l'expression range jusqu'à la fin de la boucle.

Copie accidentelle d'objets lourds

std::vector<std::string> logs = get_logs();  // milliers d'entrées

// ❌ Copie chaque std::string à chaque itération
for (auto log : logs) {
    std::cout << log << "\n";
}

// ✅ Référence constante — aucune copie
for (const auto& log : logs) {
    std::cout << log << "\n";
}

Sur un vecteur de 10 000 chaînes, la différence peut se chiffrer en millisecondes. Avec -Wall -Wextra, certains compilateurs ne signalent malheureusement pas ce cas — c'est au développeur d'y penser.

Modifier le conteneur pendant le parcours

std::vector<int> nums = {1, 2, 3, 4, 5};

// ❌ Comportement indéfini — push_back peut invalider les itérateurs
for (auto n : nums) {
    if (n == 3) {
        nums.push_back(30);
    }
}

Toute opération qui invalide les itérateurs (insertion, suppression, réallocation) est interdite pendant un range-based for. Si vous devez modifier la structure du conteneur, utilisez une boucle avec itérateur explicite ou accumulez les modifications pour les appliquer après la boucle.


Récapitulatif : quelle boucle choisir ?

Situation Boucle recommandée
Parcourir tous les éléments d'un conteneur for (const auto& x : conteneur)
Modifier chaque élément en place for (auto& x : conteneur)
Besoin d'un index for classique avec std::size_t
Pas d'itération non standard for classique
Parcours inverse for avec rbegin()/rend() ou std::views::reverse (C++20)
Condition d'arrêt dynamique (non liée à un conteneur) while ou do-while
Lecture de flux (fichier, stdin, socket) while avec std::getline ou opérateur >>

Ce qui suit

La section 4.1 est terminée. La section 4.2 aborde un sujet tout aussi fondamental : la déclaration et la définition de fonctions en C++, avec la distinction entre prototype et implémentation, la séparation .h/.cpp, et la règle ODR (One Definition Rule).

⏭️ Déclaration et définition de fonctions