Programmation • IoT

Cours C++

  • 42 Document
  • septembre 5, 2026 Updated
  • Auteur

Partie I : Les bases du C++

Types de données, boucles, références, pointeurs, programmation non orientée objet

Chapitre 1 : Hello World et structures de base + Exercices

Chapitre 1 : Hello World et structures de base + Exercices

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
1 Programme Hello World 1.3 **Programme Hello World plus avancé . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4 Les types fondamentaux du C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.1.1 Illustration du type char . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.1.2 Valeur maximale d’une variable de type char . . . . . . . . . . . . . 1.4.4 Les types à taille fixe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5 Les structures fondamentales du langage C++ . . . . . . . . . . . . . . . . . . . . . . . 1.5.1.1 Exemple 1 : La portée . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.1.2 Exemple 2 : Le masquage . . . . . . . . . . . . . . . . . . . . . . . . 1.5.1.3 Exemple 3 : La durée de vie . . . . . . . . . . . . . . . . . . . . . . . 1.5.2.2 Les branches else if et else . . . . . . . . . . . . . . . . . . . . . 1.5.2.3 Conversion implicite vers bool . . . . . . . . . . . . . . . . . . . . . 1.5.2.4 Affectation ou comparaison ? . . . . . . . . . . . . . . . . . . . . . . 1.5.2.5 Évaluation paresseuse des opérateurs logiques . . . . . . . . . . . . 1.5.2.7 Le problème du else ambigu . . . . . . . . . . . . . . . . . . . . . . 1.5.2.8 L’instruction switch . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.3.4 Pré-incrémentation et post-incrémentation . . . . . . . . . . . . . . 1.5.3.5 Les boucles infinies . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.3.6 L’instruction break . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.3.7 L’instruction break . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.3.8 L’instruction continue . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.3.9 L’instruction goto . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.4 Les instructions de contrôle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.4.1 L’instruction break . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.4.2 L’instruction continue . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.4.3 L’instruction return . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.4.4 L’instruction goto . . . . . . . . . . . . . . . . . . . . . . . . . . . . Programme Hello World 1.5.6.1 Les opérateurs arithmétiques . . . . . . . . . . . . . . . . . . . . . . 1.5.6.2 Les opérateurs d’affectation . . . . . . . . . . . . . . . . . . . . . . . 1.5.6.3 Les opérateurs d’incrémentation . . . . . . . . . . . . . . . . . . . . 1.5.6.4 Les opérateurs de comparaison . . . . . . . . . . . . . . . . . . . . . 1.5.6.5 Les opérateurs logiques . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.6.6 Priorité des opérateurs . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.6.7 Les opérateurs binaires . . . . . . . . . . . . . . . . . . . . . . . . . . J’ai découvert la programmation à l’âge de 17 ans au Collège de Genève, avec le langage FORTRAN sur cartes perforées. Depuis cette époque, les langages ont profondément évolué : programmation structurée avec Pascal, arrivée du C, puis du C++ et de la programmation orientée objet. Malgré ces évolutions, une chose n’a pas changé : comprendre ce qui se passe réellement en mémoire reste la meilleure façon d’apprendre à programmer. C’est l’esprit dans lequel cet ouvrage a été conçu.
Accueil
Programme Hello World Le plus petit programme C++ conforme au standard est : Code 1.1 – Programme C++ vraiment minimal Ce programme ne produit aucune sortie visible. En revanche, il est bien compilé, chargé en mémoire, exécuté, puis termine son exécution en retournant la valeur 0. int main() est une fonction 1 Dans ce cas, la valeur retournée est de type int c’est un entier relatif. La fonction s’appelle main et ne prend aucun paramètre en entrée (la parenthèse est vide). main est la fonction que le compilateur cherche en premier lieu (elle doit s’appeler int main() car c’est le point d’entrée dans le programme. L’entier retourné est 0 si le programme se déroule bien (ou une autre valeur programmable si on veut signaler une erreur ou un comportement particulier). Attention ! Ce n’est pas un booléen, c’est un entier ! 0 ne doit pas être traduit par false. Programme « Hello World ! » Dans les IDE (Environnement de Développement Intégré) tels Xcode, CLion ou autre, le programme minimum ou généralement appelé Programme « Hello world ! » est de la forme suivante : Code 1.2 – Premier programme Cpp minimal int main(int argc, const char * argv[]) { std::cout << « Hello, World! » << std::endl; Étudions un peu ce programme qui amène déjà des instructions que l’on ne verra qu’assez loin dans le cours. Pour l’instant il suffit de savoir que la ligne #include <iostream> appelle une bibliothèque 1. Une fonction est un bloc d’instructions pouvant être exécuté lorsqu’il est appelé. Programme Hello World qui va fournir les fonctions std::cout, std::endl ainsi que l’opérateur d’écriture sur la console <<. On remarquera que les paramètres de int sont int argc et const char ∗ argv[]. Le premier des paramètres indique le nombre d’entrées exactes lors de l’appel de la fonction. Les variables const char ∗ argv[] sont des chaines de caractères. Pour l’instant, on en sait assez pour commencer à programmer. On reviendra sur les paramètres possibles de la fonction principale dès que l’on aura vu les pointeurs. **Programme Hello World plus avancé Code 1.3 – Programme C++ Hello World plus élaboré int main(int argc, char* argv[]) { std::cout << « Nombre d’arguments :  » << argc << « \n »; for (int i = 0; i < argc; i++) { std::cout << « Argument  » << i <<  » :  » << argv[i] << « \n »; La compilation se fait dans un terminal (et depuis le dossier où se trouve main.cpp) avec la commande : g++ main.cpp −o main Une fois compilé, le programme est lancé avec la commande avec ou sans argument. Si on exécute le programme sans aucune valeurs d’entrée càd avec la commande ./main on obtient Nombre d’arguments : 1 On voit que même sans argument donné, il y en a un qui est le nom du programme lui-même. Si on lance ./main ceci est le programme Hello World 1 23 4 5 h dont la sortie sur le terminal donne : Nombre d’arguments : 12 Argument 4 : programme
Accueil
Programme Hello World On voit que la variable argv[] est un tableau de chaînes de carctères. On reviendra sur la signification du symbole ∗ dans char ∗ argv[] plus loin dans le cours • Tout programme C++ possède une fonction main(). • L’exécution commence toujours dans cette fonction. • La valeur retournée indique comment le programme s’est terminé. • Un programme peut recevoir des arguments lors de son lancement. Les types fondamentaux du C++ Valeur logique (true ou false) Caractère ASCII ou petit entier 8 octets (Mac/Linux) * Les tailles indiquées correspondent aux plateformes 64 bits les plus courantes (macOS/Linux). Elles peuvent varier suivant le compilateur et le système d’exploitation. Table 1.1 – Principaux types entiers. Version non signée de char Version non signée de short Version non signée de int 8 octets (Mac/Linux) Version non signée de long Version non signée de long long Les types non signés permettent uniquement de représenter des valeurs positives ou nulles. En contrepartie, ils offrent une plage de valeurs positives environ deux fois plus grande que les types signés de même taille. Table 1.2 – Versions non signées des types entiers.
Accueil
Programme Hello World Le type char occupe un octet comme signed char et unsigned char. Sa particularité est d’être destiné principalement à la représentation des caractères. En réalité, un caractère est mémorisé sous la forme d’un entier correspondant à son code (par exemple le code ASCII ou Unicode). Le standard C++ ne précise pas si char est signé ou non signé : ce choix dépend du compilateur et de la plateforme. Lorsqu’il est nécessaire de lever toute ambiguïté, on utilise explicitement signed char ou unsigned char. Illustration du type char std::cout << c << ’\n’; std::cout << static_cast<int>(c) << ’\n’; Valeur maximale d’une variable de type char Il faut distinguer la capacité du type char et les codes effectivement utilisés. • Valeur maximale d’un char Cela dépend de son signe. 1. Si char est signé (signed char) : minimum : -128 et maximum : 127. 2. Si char est non signé (unsigned char) : minimum : 0 et maximum = 255. Comme le standard C++ ne précise pas si char est signé ou non, il est préférable de ne pas supposer sa plage de valeurs. • Valeurs effectivement utilisées Cela dépend de l’encodage. En ASCII (historique) on utilise seulement 128 codes, de 0 à 127.
Accueil
Programme Hello World Historiquement, un char représentait un caractère codé sur un octet (ASCII). Aujourd’hui, avec l’encodage UTF-8, un caractère peut occuper plusieurs octets. Le type char représente donc un octet, et non toujours un caractère complet. Pour l’instant, par souci de simplicité, on ne s’occupe que de l’encodage ASCII. 16 octets (selon plateforme) * Les nombres réels sont stockés selon la norme IEEE 754. Table 1.3 – Principaux types réels. Les tailles mémoire Les résultats dépendent de la plateforme. Code 1.5 – Tailles de différents types std::cout << sizeof(bool) << ’\n’; std::cout << sizeof(char) << ’\n’; std::cout << sizeof(short) << ’\n’; std::cout << sizeof(int) << ’\n’; std::cout << sizeof(long) << ’\n’; std::cout << sizeof(long long) << ’\n’; std::cout << sizeof(float) << ’\n’; std::cout << sizeof(double) << ’\n’; Program ended with exit code: 0 Les types à taille fixe Jusqu’à présent, nous avons utilisé des types tels que int, long ou short. Bien qu’ils soient très pratiques, leur taille n’est pas entièrement fixée par le standard C++. Par exemple, un long occupe généralement 8 octets sur les ordinateurs récents sous macOS ou Linux, mais seulement 4 octets sur
Accueil
Programme Hello World certains systèmes Windows. Un même programme peut donc manipuler des tailles différentes selon la plateforme utilisée. Dans la plupart des applications, cette différence n’a aucune conséquence. En revanche, elle devient critique lorsqu’un programme communique avec du matériel électronique, lit ou écrit un fichier binaire, échange des données sur un réseau ou utilise un protocole de communication précis. Pour répondre à ce besoin, la bibliothèque standard C++ définit des types dont la taille est garantie. Leur nom indique directement le nombre de bits utilisés. Ainsi, int8_t représente un entier signé de 8 bits, uint16_t un entier non signé de 16 bits et uint32_t un entier non signé de 32 bits. Ces types sont très utilisés en programmation embarquée, notamment avec l’ESP32 et ESP-IDF, où la taille exacte des données est souvent imposée par le matériel ou par les protocoles de communication. Table 1.4 – Les types entiers à taille fixe définis dans <cstdint>. Les types à taille fixe ont été introduits avec la norme C99, puis repris par le C++ (dans l’entête <cstdint>). Ils sont aujourd’hui largement utilisés dans les domaines où la portabilité et la compatibilité matérielle sont essentielles. Le type size_t est utilisé pour représenter la taille d’un objet ou le nombre d’éléments contenus dans une structure de données. Il est renvoyé notamment par l’opérateur sizeof() et par les fonctions size() de nombreuses classes de la bibliothèque standard 2 . Les structures fondamentales du langage C++ Un bloc est une suite d’instructions comprise entre deux accolades : 2. On reviendra sur le type size_t par la suite
Accueil
Programme Hello World Exemple 1 : La portée Code 1.8 – La portée d’un bloc std::cout << « a =  » << a << ’\n’; std::cout << « b =  » << b << ’\n’; std::cout << « a =  » << a << ’\n’; ne compile pas si on ajoute la ligne std::cout << « b =  » << b << ’\n’; en dessous de la ligne std::cout << « a =  » << a << ’\n’;. La variable b n’existe qu’entre les accolades où elle a été définie. Lorsque l’exécution quitte ce bloc, la variable est automatiquement détruite. Les blocs permettent de regrouper plusieurs instructions. Ils jouent également un rôle fondamental dans la gestion de la portée des variables et de la durée de vie des objets. Exemple 2 : Le masquage Code 1.9 – Masquage std::cout << a << ’\n’; std::cout << a << ’\n’; La seconde variable ne remplace pas la première. Elle la masque simplement à l’intérieur du bloc. Exemple 3 : La durée de vie Code 1.10 – Durée de vie
Accueil
Programme Hello World À la fermeture de l’accolade : 1. la mémoire de a est libérée ; 2. l’objet cesse d’exister. En C++, les accolades ne servent pas uniquement à regrouper des instructions. Elles définissent un bloc dans lequel peuvent être déclarées des variables. La portée de ces variables est limitée à ce bloc, et elles sont automatiquement détruites lorsque l’exécution en sort. Cette notion est fondamentale et sera réutilisée tout au long du langage. uniquement à améliorer la présentation du code. Elles définissent la portée des variables et la durée de vie des objets. Code 1.11 – Piège 1 ne compile pas car une déclaration n’est pas une instruction simple. Il faut écrire : Code 1.12 – Manière juste d’écrire 2. Ce code ne compile pas non plus : Code 1.13 – Piège 2 for (int i = 0; i < 10; ++i) car int i est déclaré dans le bloc du for. En C++, la destruction automatique des objets à la sortie d’un bloc est un mécanisme fondamental. C’est lui qui permettra plus tard de comprendre le fonctionnement du RAII, des flux de fichiers (std::ifstream, std::ofstream), des verrous (std::lock_guard) et des pointeurs intelligents
Accueil
Programme Hello World • Les accolades définissent un bloc. • Un bloc délimite la portée des variables. • Une variable n’existe que dans le bloc où elle est déclarée. • À la fin d’un bloc, les objets locaux sont automatiquement détruits. • Une variable peut masquer une autre variable de même nom dans un bloc intérieur. Les premiers langages de programmation, tels que le FORTRAN, ne possédaient pas de véritables blocs d’instructions. Plus tard, des langages comme Pascal ont introduit les mots-clés begin et end pour délimiter les blocs. Le langage C, puis le C++, ont remplacé ces mots-clés par les accolades { et }, plus compactes et plus faciles à lire lorsque les blocs sont fortement Les structures conditionnelles permettent de sélectionner les instructions qui seront exécutées en fonction de la valeur d’une expression. En C++, la condition d’un test est convertie en une valeur de type bool. Une expression n’a donc pas nécessairement besoin d’être explicitement égale à true ou à false. La forme générale d’un test est la suivante : // Instructions executees si la condition est vraie La condition est évaluée une seule fois. Si elle vaut true, le bloc est exécuté. Dans le cas contraire, Code 1.14 – Test simple int temperature = 18; if (temperature < 20) std::cout << « La temperature est inferieure a 20 degres.\n »; La temperature est inferieure a 20 degres. int temperature = 18; std::cout << « La temperature est inferieure a 20 degres.\n »;
Accueil
Programme Hello World donne également la sortie La temperature est inferieure a 20 degres. if (0.000000000000001) std::cout << « Vrai\n »; std::cout << « Faux\n »; std::cout << « Vrai\n »; std::cout << « Faux\n »; En C++, lorsqu’une valeur numérique est utilisée comme condition, elle est convertie implicitement en bool selon une règle très simple : • 0 ou 0.0 donne false • toute autre valeur donne true Lorsqu’une valeur numérique est utilisée comme condition, seule la valeur exactement égale à zéro est convertie en false. Toute autre valeur, même extrêmement petite, est convertie en Ainsi, les deux tests suivants donnent des résultats différents :
Accueil
Programme Hello World Lorsqu’une condition est une constante connue à la compilation (par exemple if (0.0) ou if (1)), le compilateur ou l’environnement de développement peut détecter qu’une branche ne sera jamais exécutée et émettre un avertissement. Il ne s’agit pas d’une erreur de syntaxe, mais d’une optimisation et d’une aide à la détection d’erreurs logiques. Les premières versions du langage C ne possédaient pas de type booléen. Les tests conditionnels utilisaient directement des valeurs entières : la valeur zéro représentait une condition fausse, tandis que toute valeur non nulle représentait une condition vraie. Le C++ a introduit le type bool, mais a conservé cette conversion implicite afin de rester compatible avec l’immense quantité de code C déjà existante. Les branches else if et else Plusieurs cas peuvent être distingués grâce à une chaîne de tests : std::cout << « Tres bien\n »; else if (note >= 4.0) std::cout << « Suffisant\n »; std::cout << « Insuffisant\n »; Les conditions sont évaluées dans l’ordre. Dès qu’une condition est vraie, le bloc correspondant est exécuté et les branches suivantes sont ignorées. Conversion implicite vers bool Une condition n’est pas obligatoirement le résultat d’une comparaison explicite. De nombreuses valeurs peuvent être converties en bool. Pour les types entiers : • la valeur zéro est convertie en false ; • toute valeur non nulle est convertie en true (voir ci-dessus avec l’exemple if (330) …. std::cout << « n est non nul\n »;
Accueil
Programme Hello World std::cout << *p << ’\n’; ne donne pas de sortie. Dans un test, l’expression if (p) est équivalente à if (p != nullptr). La seconde écriture est plus explicite, tandis que la première est très courante dans le code Affectation ou comparaison ? L’opérateur = réalise une affectation, tandis que l’opérateur == réalise une comparaison. Ces deux instructions ont des significations très différentes : // Affectation, puis conversion en bool, attention si b=0 alors if (a = b) retourne false La seconde écriture est syntaxiquement valide. La valeur de b est affectée à a, puis la nouvelle valeur de a est convertie en bool. La confusion entre = et == est une erreur classique. Lorsque l’affectation dans une condition est réellement volontaire, il est préférable de la rendre explicite au moyen de parenthèses supplémentaires. Évaluation paresseuse des opérateurs logiques Les opérateurs logiques && et || utilisent une évaluation dite paresseuse ou en court-circuit. condition1 && condition2 la seconde condition n’est évaluée que si la première est vraie. Dans l’expression condition1 || condition2 la seconde condition n’est évaluée que si la première est fausse.
Accueil
Programme Hello World if (p != nullptr && *p > 0) std::cout << « Valeur positive\n »; La première condition est fausse. L’expression ∗p > 0 n’est donc jamais évaluée, ce qui évite de déréférencer un pointeur nul. • Avec &&, l’évaluation s’arrête dès qu’une condition est fausse. • Avec ||, l’évaluation s’arrête dès qu’une condition est vraie. • L’ordre des conditions peut donc avoir une importance fondamentale. Le langage autorise l’omission des accolades lorsqu’un seul bloc d’instruction suit le test : std::cout << « Positif\n »; Cette écriture est correcte, mais elle devient rapidement dangereuse lorsque le programme est modifié. std::cout << « Positif\n »; Seule la première instruction dépend du test. L’incrémentation de compteur est toujours exécutée, malgré l’indentation trompeuse. Dans cet ouvrage, les accolades seront donc utilisées systématiquement. Le problème du else ambigu Lorsqu’un test est imbriqué sans accolades, un else est toujours associé au if précédent le plus proche qui ne possède pas encore de branche else. std::cout << « a et b sont positifs\n »; std::cout << « b n’est pas positif\n »; Le else est associé au second if, et non au premier. L’utilisation systématique des accolades élimine L’instruction switch Lorsque plusieurs choix dépendent de la valeur entière d’une même expression, l’instruction switch peut être plus lisible qu’une succession de if et de else if.
Accueil
std::cout << « Premier choix\n »; std::cout << « Deuxieme choix\n »; Programme Hello World std::cout << « Troisieme choix\n »; std::cout << « Choix inconnu\n »; Sans l’instruction break, l’exécution continue dans les branches suivantes. Ce comportement est appelé fallthrough. std::cout << « Acces niveau 3\n »; // poursuite volontaire std::cout << « Acces niveau 2\n »; // poursuite volontaire std::cout << « Acces niveau 1\n »; std::cout << « Aucun acces\n »; L’absence involontaire de break dans une branche case est une source classique d’erreurs. En C++ 11, lorsqu’une poursuite vers la branche suivante est volontaire, elle doit être clairement signalée par un commentaire. L’expression contrôlée par switch doit être de type entier, énuméré ou convertible vers un type entier. On ne peut pas utiliser directement un double ou une chaîne de caractères comme expression
Accueil
Programme Hello World • Une condition est convertie en une valeur de type bool. • Zéro correspond à false ; toute valeur entière non nulle correspond à true. • Un pointeur nul est converti en false. • = réalise une affectation, tandis que == réalise une comparaison. • Les opérateurs && et || utilisent une évaluation en court-circuit. • Les accolades doivent être utilisées systématiquement, même pour une seule instruction. • Dans un switch, l’instruction break empêche généralement l’exécution de la branche Les boucles permettent de répéter l’exécution d’un même bloc d’instructions tant qu’une condition Le langage C++ propose trois structures de répétition : • la boucle while ; • la boucle do…while ; Bien que leur syntaxe soit différente, ces trois structures permettent de réaliser les mêmes traitements. Le choix de l’une ou de l’autre dépend essentiellement de la lisibilité du programme. La boucle while exécute un bloc d’instructions tant que sa condition reste vraie. La condition est évaluée avant chaque itération. Si elle est fausse dès le départ, le bloc n’est jamais Code 1.15 – Compteur de 0 à 4 std::cout << i << ’\n’; Program ended with exit code: 0
Accueil
Programme Hello World La boucle do…while Contrairement à la boucle while, la boucle do…while execute toujours son bloc au moins une std::cout << i << ’\n’; La sortie est naturellement Program ended with exit code: 0 Même si la condition est fausse, le nombre 5 est affiché une fois. La boucle for est particulièrement adaptée lorsque le nombre d’itérations est connu ou lorsqu’un compteur est utilisé. Sa syntaxe générale est : for (initialisation ; condition ; mise_a_jour) Les trois expressions placées entre les parenthèses jouent des rôles différents : • l’initialisation est exécutée une seule fois ; • la condition est évaluée avant chaque itération ; • la mise à jour est exécutée à la fin de chaque tour de boucle. Code 1.16 – Affichage des dix premiers entiers for (int i = 0 ; i < 10 ; ++i) std::cout << i << ’\n’;
Accueil
Programme Hello World Program ended with exit code: 0 Cette boucle est strictement équivalente à : std::cout << i << ’\n’; dont la sortie est identique, à savoir Program ended with exit code: 0 La boucle for constitue donc essentiellement une écriture plus compacte du même algorithme. Pré-incrémentation et post-incrémentation Deux opérateurs permettent d’incrémenter une variable : Ils produisent le même effet sur la variable, mais ne retournent pas la même valeur. Après l’exécution : i vaut 6 et a vaut également 6.
Accueil
Programme Hello World Après l’exécution : i vaut 6 tandis que a vaut 5. Dans une boucle for, la valeur retournée par l’opérateur n’est généralement pas utilisée. Les deux écritures suivantes sont donc équivalentes : for (int i = 0 ; i < 100 ; ++i) for (int i = 0 ; i < 100 ; i++) Remarque 1.5.1. Avec certaines classes, notamment les itérateurs de la bibliothèque standard, la pré-incrémentation (++it) peut être légèrement plus efficace que la post-incrémentation (it++), car elle évite la création d’une copie temporaire de l’objet. C’est pourquoi la pré-incrémentation est souvent privilégiée dans le code C++ moderne. Pour un type fondamental, on peut considérer que ++i est équivalent à incrémenter d’abord la variable puis à utiliser sa nouvelle valeur. De même, i++ utilise d’abord la valeur actuelle puis effectue l’incrémentation. i = i + 1; puis utiliser la nouvelle valeur de i. Utiliser la valeur actuelle de i, puis exécuter i = i + 1;. Table 1.5 – Différence entre pré-incrémentation et post-incrémentation. Remarque 1.5.2. Ce tableau décrit le comportement conceptuel des deux opérateurs. Le compilateur est libre d’optimiser les instructions générées, mais le résultat observable reste toujours celui décrit Les boucles infinies Une boucle dont la condition reste toujours vraie ne se termine jamais. Les boucles infinies sont utilisées dans certains domaines, notamment pour les serveurs ou les systèmes embarqués, où un programme doit rester actif tant que la machine fonctionne. L’instruction break L’instruction break interrompt immédiatement la boucle la plus interne.
Accueil
Programme Hello World L’instruction break L’instruction break interrompt immédiatement la boucle la plus interne. L’instruction continue L’instruction continue interrompt uniquement l’itération en cours. L’exécution reprend directement au début de l’itération suivante. for (int i = 0 ; i < 10 ; ++i) std::cout << i << ’\n’; Le nombre 5 n’est pas affiché. L’instruction goto L’instruction goto permet de transférer directement l’exécution vers une étiquette située ailleurs dans la même fonction. Bien que cette instruction fasse toujours partie du langage C++, son utilisation est aujourd’hui très rare. Dans la plupart des situations, les structures de contrôle (if, for, while, break, continue et return) permettent d’obtenir un code plus lisible et plus facile à maintenir. std::cout << « Le nombre est positif.\n »; std::cout << « Erreur : le nombre est negatif.\n »;
Accueil
Programme Hello World Bien que l’instruction goto fasse toujours partie du langage C++, elle est aujourd’hui très rarement utilisée. Dans la quasi-totalité des cas, les structures de contrôle (if, for, while, break, continue et return) permettent d’écrire un code plus clair et plus facile à maintenir. • Les boucles while, do…while et for permettent de répéter un traitement. • La boucle while peut ne jamais être exécutée. • La boucle do…while est exécutée au moins une fois. • La boucle for regroupe l’initialisation, le test et la mise à jour. • Les opérateurs ++i et i++ ne retournent pas la même valeur. • Dans une boucle for, la pré-incrémentation est généralement préférée. • Les instructions break et continue modifient le déroulement normal d’une boucle. • Le goto n’est pas interdit. Si vous ressentez le besoin de l’utiliser, demandez-vous d’abord si votre algorithme ou la structure de votre programme ne pourrait pas être Les instructions de contrôle Les structures de contrôle étudiées jusqu’à présent (if, switch, while, do…while et for) déterminent le déroulement normal d’un programme. Le langage C++ fournit également plusieurs instructions permettant de modifier ce déroulement en interrompant une boucle, en quittant une fonction ou en effectuant un saut vers une autre partie du L’instruction break L’instruction break provoque l’arrêt immédiat de la boucle ou du switch le plus interne. L’exécution reprend alors avec la première instruction située après cette structure. Code 1.17 – Utilisation de break for (int i = 0; i < 10; ++i) std::cout << i << ’\n’; qui donne comme sortie
Accueil
Programme Hello World Program ended with exit code: 0 Remarque 1.5.3. L’instruction break ne quitte que la structure de contrôle la plus interne. Lorsqu’une boucle est imbriquée dans une autre, seule la boucle courante est interrompue. L’instruction continue L’instruction continue interrompt uniquement l’itération en cours. L’exécution reprend immédiatement à l’itération suivante de la boucle. Code 1.18 – Utilisation de continue for (int i = 0; i < 10; ++i) if (i == 5) continue; std::cout << i << ’\n’; Program ended with exit code: 0 Le nombre 5 n’est jamais affiché. Remarque 1.5.4. Dans une boucle for, l’expression de mise à jour est tout de même exécutée après un continue. Dans une boucle while, il appartient au programmeur de veiller à ce que la variable de contrôle soit correctement mise à jour afin d’éviter une boucle infinie. L’instruction return L’instruction return termine immédiatement l’exécution de la fonction courante 3 . Lorsqu’une valeur est attendue, elle est renvoyée à la fonction appelante. 3. Pour les fonctions voyez le paragraphe suivant.
Accueil
Programme Hello World std::cout << « Bonjour\n »; std::cout << « Ce texte ne sera jamais affiche.\n »; Remarque 1.5.5. L’instruction return met immédiatement fin à l’exécution de la fonction. Toutes les instructions situées après un return ne seront donc jamais exécutées. L’instruction goto L’instruction goto permet d’effectuer un saut direct vers une étiquette située ailleurs dans la même Code 1.19 – Exemple d’utilisation de goto std::cout << « Nombre positif\n »; std::cout << « Erreur\n »; Les premiers langages de programmation, notamment FORTRAN, utilisaient très largement l’instruction goto. Cette pratique conduisait souvent à un code difficile à comprendre, surnommé spaghetti code. À partir des années 1970, la programmation structurée a progressivement remplacé cette approche par des structures comme if, while et for. Aujourd’hui, l’utilisation de goto reste • break interrompt immédiatement la boucle ou le switch le plus interne. • continue interrompt uniquement l’itération courante d’une boucle. • return termine immédiatement l’exécution de la fonction courante. • goto effectue un saut direct vers une étiquette située dans la même fonction. • Les instructions de contrôle doivent être utilisées avec modération afin de préserver la lisibilité des programmes. • L’instruction goto provoque un saut vers une étiquette (ou label) située ailleurs dans la
Accueil
Programme Hello World Les commentaires permettent d’ajouter des explications dans un programme sans influencer son exécution. Ils sont ignorés par le compilateur et servent uniquement à améliorer la compréhension du code par le programmeur. Le C++ propose deux types de commentaires. Commentaires sur une ligne Un commentaire commençant par // se poursuit jusqu’à la fin de la ligne. // Age de l’etudiant double pi = 3.14159; // Valeur approchee de Ce type de commentaire est particulièrement adapté pour de courtes explications. Commentaires sur plusieurs lignes Lorsqu’une explication s’étend sur plusieurs lignes, on peut utiliser les délimiteurs /∗ et ∗/. Calcul de la moyenne des notes. Les notes sont supposees comprises Ces commentaires peuvent s’étendre sur plusieurs lignes, mais ils ne peuvent pas être imbriqués. Les commentaires de type /∗ … ∗/ ne peuvent pas être placés à l’intérieur d’un autre commentaire du même type. Pourquoi commenter un programme ? Le but d’un commentaire n’est pas de décrire chaque instruction, mais d’expliquer ce que le code ne peut pas exprimer naturellement. Par exemple, le commentaire suivant est inutile : En revanche, le commentaire ci-dessous apporte une information précieuse : // Recherche du premier element negatif du tableau. for (int i = 0; i < n; ++i) Le lecteur comprend immédiatement l’objectif de l’algorithme avant même d’en examiner les détails.
Accueil
Programme Hello World Avant d’ajouter un commentaire, demandez-vous si un nom de variable ou de fonction plus explicite ne permettrait pas de s’en passer. double vitesseMoyenne; est beaucoup plus explicite que Un bon choix de noms améliore la lisibilité du programme et réduit le besoin de commentaires. Dans les premiers langages de programmation, les programmes étaient souvent peu structurés et les noms de variables très courts. Les commentaires étaient alors indispensables pour comprendre le fonctionnement du code. Aujourd’hui, les langages modernes permettent d’utiliser des identificateurs explicites et de découper un programme en fonctions courtes. Les commentaires restent importants, mais ils servent surtout à expliquer les choix de conception, les hypothèses ou les algorithmes utilisés. • Les commentaires sont ignorés par le compilateur. • // permet d’écrire un commentaire jusqu’à la fin de la ligne. • /∗ … ∗/ permet d’écrire un commentaire sur plusieurs lignes. • Un bon commentaire explique une intention ou un choix, et non une instruction évidente. • Un code clair et des noms de variables explicites nécessitent généralement peu de commentaires. Les opérateurs permettent d’effectuer des calculs, de comparer des valeurs ou de modifier le contenu des variables. Certains sont hérités directement des mathématiques, tandis que d’autres sont propres Cette section présente les opérateurs les plus couramment utilisés. Les opérateurs plus spécialisés (opérateurs binaires, surcharge d’opérateurs, etc.) seront étudiés dans les chapitres correspondants. Les opérateurs arithmétiques Reste de la division entière
Accueil
Programme Hello World cout << a / b << endl; cout << 7.0 / 2 << endl; Lorsque les deux opérandes sont des entiers, la division est entière. La partie décimale est Les opérateurs d’affectation // x vaut maintenant 17 // x vaut maintenant 34 Les opérateurs d’incrémentation Les opérateurs d’incrémentation et de décrémentation permettent d’ajouter ou de retrancher une Remarque 1.5.6. La différence entre ++i et i++ sera étudiée plus loin dans le livre. Les opérateurs de comparaison Les opérateurs de comparaison renvoient une valeur booléenne (true ou false).
Accueil
Programme Hello World Plus petit ou égal à Plus grand ou égal à qui réalise une affectation, avec qui effectue une comparaison. Les opérateurs logiques Les opérateurs logiques permettent de combiner plusieurs conditions. if (age >= 18 && permis) Priorité des opérateurs Comme en mathématiques, certains opérateurs sont évalués avant d’autres. Dans les cas les plus courants : 2. Multiplication, division, modulo 3. Addition, soustraction 5. Opérateurs logiques En cas de doute, ajoutez des parenthèses. Elles améliorent souvent la lisibilité du programme.
Accueil
Programme Hello World Les opérateurs binaires Le C++ possède également des opérateurs agissant directement sur les bits. Ces opérateurs sont très utilisés en programmation système et dans le développement pour microcontrôleurs (ESP32, STM32, Arduino, etc.). Ils seront étudiés en détail dans un chapitre spécifique.
Accueil
cpp-Chap1- Programme Hello World
Accueil

Chapitre 1 – Exercices

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
1 Exercices Chapitre 1 – Partie I À propos de la présentation du code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Exercice 1 — Portée, blocs et masquage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Solution de l’exercice 1 — Portée, blocs et masquage . . . . . . . . . . . . . . . . . . . . . Exercice 2 – Le programme qui ne fait pas ce qu’il semble faire . . . . . . . . . . . . . . . . Solution de l’exercice 2 – Le programme qui ne fait pas ce qu’il semble faire . . . . . . . . Exercice 3 – Représentation des entiers et portabilité . . . . . . . . . . . . . . . . . . . . . . Solution de l’exercice 3 – Représentation des entiers et portabilité . . . . . . . . . . . . . . Exercice 4 – Division euclidienne sans flottants . . . . . . . . . . . . . . . . . . . . . . . . . Solution de l’exercice 4 – Division euclidienne sans flottants . . . . . . . . . . . . . . . . . . Exercice 5 – Décomposition décimale d’un entier . . . . . . . . . . . . . . . . . . . . . . . . Solution de l’exercice 5 – Décomposition décimale d’un entier . . . . . . . . . . . . . . . . . Exercice 6 – Recherche d’un nombre premier . . . . . . . . . . . . . . . . . . . . . . . . . . . Solution de l’exercice 6 – Recherche d’un nombre premier . . . . . . . . . . . . . . . . . . . Exercices – Partie I – Chapitre 1
Accueil
Exercices Chapitre 1 – Partie I À propos de la présentation du code Dans les exercices et leurs corrections, la mise en page du code répond avant tout à des objectifs pédagogiques. Les indentations, les retours à la ligne, les espaces et la position des accolades sont choisis de manière à rendre aussi clairement que possible la structure logique du programme. Il ne s’agit pas d’imposer un style unique. Avec l’expérience, chacun développera naturellement ses propres habitudes de présentation, pour autant que le code reste cohérent et facilement lisible. Dans les corrections, nous privilégierons donc parfois une écriture plus développée que strictement nécessaire. Par exemple : peut parfaitement être écrit : if (n % 2 == 0) premier = false; puisque le bloc contrôlé par le if ne contient qu’une seule instruction. La première forme permet cependant de visualiser immédiatement le bloc associé à la condition. Elle peut donc être préférable dans un exemple destiné à expliquer le fonctionnement d’une structure de Inversement, lorsqu’une écriture plus compacte permet de mieux faire ressortir le raisonnement sans nuire à sa compréhension, elle pourra également être utilisée. L’objectif des corrections n’est donc pas de proposer une mise en page à reproduire mécaniquement, mais de montrer clairement la structure du programme et le rôle de chacune de ses parties. La compréhension doit précéder le choix du style. Finalement, chacun trouvera son style, on est pas sous Python 😉 ! Exercices – Partie I – Chapitre 1 Exercice 1 — Portée, blocs et masquage On considère le programme suivant : std::cout << « 1 :  » << a <<  »  » << b << ’\n’; std::cout << « 2 :  » << a <<  »  » << b << ’\n’; std::cout << « 3 :  » << a <<  »  » << b << ’\n’; std::cout << « 4 :  » << a <<  »  » << b << ’\n’; std::cout << « 5 :  » << a <<  »  » << b << ’\n’; std::cout << « 6 :  » << a <<  »  » << b << ’\n’; std::cout << « 7 :  » << a <<  »  » << b << ’\n’; Sans compiler ni exécuter le programme dans un premier temps : 1. Déterminer exactement ce qui est affiché par chacune des sept instructions std::cout. 2. Pour chaque affichage, préciser à quelle déclaration correspondent les variables a et b utilisées. 3. Identifier toutes les variables distinctes créées au cours de l’exécution du programme. Deux variables portant le même nom mais déclarées dans des blocs différents doivent être considérées comme deux variables différentes. 4. Pour chacune de ces variables, indiquer : • l’endroit où commence sa portée ; • l’endroit où se termine sa portée ; • à quel moment sa durée de vie prend fin.
Accueil
Exercices – Partie I – Chapitre 1 5. Expliquer pourquoi l’instruction ne modifie pas la variable a initialisée à la valeur 10. 6. En revanche, expliquer pourquoi l’instruction modifie bien la variable b initialisée à la valeur 20. 7. On ajoute l’instruction suivante immédiatement après la fermeture du bloc dans lequel b est initialisée à 40 : std::cout << b << ’\n’; Cette instruction compile-t-elle ? Si oui, quelle valeur affiche-t-elle et pourquoi ? 8. On ajoute maintenant l’instruction suivante immédiatement après la fermeture du bloc dans lequel a est initialisée par l’expression b – 5 : std::cout << a << ’\n’; Cette instruction compile-t-elle ? Si oui, quelle variable a est alors utilisée ? 9. Enfin, déterminer si les quatre variables suivantes constituent réellement quatre objets distincts Justifier la réponse en utilisant les notions de bloc, de portée, de masquage et de durée de vie. Une fois toutes les réponses établies sur papier, compiler et exécuter le programme afin de vérifier les résultats obtenus. En cas de différence entre le raisonnement et l’exécution, expliquer précisément l’origine de l’erreur de raisonnement avant de modifier les réponses.
Accueil
Exercices – Partie I – Chapitre 1 Solution de l’exercice 1 — Portée, blocs et masquage 1. Sortie du programme Le programme affiche : La difficulté de cet exercice vient du fait que plusieurs variables portent le même nom. Une nouvelle déclaration dans un bloc intérieur ne remplace pas la variable portant le même nom dans le bloc extérieur mais elle la masque temporairement. 2. Une méthode pour suivre les variables Une manière particulièrement efficace d’analyser le programme consiste à renommer mentalement les variables de même nom selon leur niveau d’imbrication. On peut par exemple utiliser : pour les trois variables appelées a, et pour les deux variables appelées b. Le programme peut alors être réécrit, uniquement pour faciliter son analyse, sous la forme suivante : std::cout << « 1 :  » << a <<  »  » << b << ’\n’; std::cout << « 2 :  » << aa <<  »  » << b << ’\n’; std::cout << « 3 :  » << aa <<  »  » << bb << ’\n’;
Accueil
std::cout << « 4 :  » << aaa <<  »  » << bb << ’\n’; std::cout << « 5 :  » << aa <<  »  » << bb << ’\n’; Exercices – Partie I – Chapitre 1 std::cout << « 6 :  » << aa <<  »  » << b << ’\n’; std::cout << « 7 :  » << a <<  »  » << b << ’\n’; Cette réécriture ne constitue naturellement pas le programme original car elle supprime volontairement le masquage des noms. Elle constitue en revanche une représentation très utile pour distinguer les différents objets et suivre leurs valeurs. 3. Analyse des sept affichages Au premier affichage, seules les variables du bloc principal existent : Dans le bloc suivant, une nouvelle variable a est créée avec la valeur 30. Elle masque la première variable a et uucune nouvelle variable b n’est déclarée dans ce bloc, l’instruction agit donc sur la variable b du bloc principal : Dans le bloc suivant, une nouvelle variable b est créée : Elle masque temporairement la variable b qui vaut 25, en revanche, aucune nouvelle variable a n’est déclarée dans ce bloc. L’instruction modifie donc la variable a du bloc précédent : Pour le 3e résultat on a :
Accueil
Exercices – Partie I – Chapitre 1 Dans le bloc le plus intérieur, une troisième variable a est créée : À cet endroit, la variable b visible est celle qui vaut 40 et la nouvelle variable vaut donc : agit également sur cette variable b, qui passe de 40 à 41. À la sortie de ce bloc, la variable a qui valait 35 est détruite. La variable a précédemment masquée redevient accessible. Elle vaut toujours 32 et la variable b valant 41 existe encore donc : À la sortie du bloc suivant, la variable b valant 41 est détruite. La variable b du bloc principal redevient donc accessible. Elle avait été modifiée précédemment et vaut 25, la variable a du bloc courant vaut toujours 32 : Enfin, lorsque ce bloc est quitté, cette variable a est également détruite et les deux variables du bloc principal sont alors de nouveau visibles : et le dernier affichage est : 4. Les objets réellement créés Le programme crée cinq variables distinctes : Nom dans le programme Nom utilisé pour l’analyse Les trois variables appelées a sont donc trois objets distincts. De même, les deux variables appelées b sont deux objets distincts. Le fait que plusieurs variables portent le même nom ne signifie pas qu’elles occupent le même emplacement mémoire.
Accueil
Exercices – Partie I – Chapitre 1 5. Portée et durée de vie Chaque variable locale commence son existence lorsque l’exécution atteint sa déclaration. Sa portée s’étend depuis sa déclaration jusqu’à la fin du bloc dans lequel elle a été déclarée, sous réserve qu’elle puisse être masquée par une nouvelle déclaration portant le même nom dans un bloc Lorsqu’une variable est masquée, elle continue d’exister mais n’est simplement plus accessible directement par son nom dans cette partie du programme. Lorsqu’on quitte le bloc dans lequel une variable locale a été créée, sa durée de vie prend fin et l’objet Il faut donc bien distinguer : • une variable qui existe et est accessible ; • une variable qui existe mais est masquée ; • une variable dont la durée de vie est terminée. 6. Pourquoi int a = 30; ne modifie-t-il pas a = 10 ? est une déclaration accompagnée d’une initialisation, elle crée une nouvelle variable appelée a dans le bloc intérieur. La variable a du bloc principal continue d’exister avec la valeur 10, mais elle est masquée tant que l’exécution reste dans la portée de la nouvelle variable. 7. Pourquoi b = b + 5; modifie-t-il b = 20 ? Dans ce bloc, aucune nouvelle variable appelée b n’a été déclarée, le compilateur recherche donc le nom b dans les blocs englobants et trouve la variable déclarée dans le bloc principal donc l’instruction est une affectation et non une déclaration donc elle modifie la variable existante : 8. Accès à b après le bloc de b = 40 Après la fermeture du bloc dans lequel la variable b valant 40 a été déclarée, cette variable est détruite. Une instruction std::cout << b << ’\n’; placée à cet endroit compile néanmoins et le nom b désigne alors de nouveau la variable du bloc principal, qui vaut : L’instruction affiche donc :
Accueil
Exercices – Partie I – Chapitre 1 9. Accès à a après le bloc le plus intérieur Après la fermeture du bloc dans lequel la variable a été déclarée, cette variable cesse d’exister. La variable a du bloc immédiatement supérieur redevient accessible avec la valeur 32. L’instruction std::cout << a << ’\n’; correspondent bien à quatre objets distincts, auxquels s’ajoute la variable b initialisée à 20. Le masquage ne détruit pas une variable et ne modifie pas sa valeur mais il rend simplement son nom temporairement inaccessible dans le bloc où une autre variable portant le même nom est visible. permet de rendre cette structure explicite et constitue une méthode simple pour analyser les blocs
Accueil
Exercices – Partie I – Chapitre 1 Exercice 2 – Le programme qui ne fait pas ce qu’il semble faire On considère le programme suivant : if (c > 1 || ++b > 0) if (a > 0 && (c = 0)) std::cout << « a =  » << a << ’\n’; std::cout << « b =  » << b << ’\n’; std::cout << « c =  » << c << ’\n’; std::cout << « d =  » << d << ’\n’;
Accueil
Exercices – Partie I – Chapitre 1 Sans compiler ni exécuter le programme dans un premier temps : 1. Déterminer les valeurs finales des variables a, b, c et d. 2. Déterminer exactement ce qui est affiché par le programme. 3. Pour chacun des six tests if, indiquer : • la valeur de l’expression conditionnelle ; • si le bloc associé est exécuté ou non ; • quelles variables sont éventuellement modifiées pendant l’évaluation de la condition ellemême. 4. Identifier toutes les affectations effectuées à l’intérieur d’une condition. Pour chacune d’elles, préciser : • la valeur affectée ; • la valeur obtenue après conversion implicite vers bool. 5. Dans l’instruction suivante : if (c > 1 || ++b > 0) déterminer si l’expression est effectivement évaluée. Justifier la réponse à l’aide du mécanisme d’évaluation en court-circuit de l’opérateur ||. 6. Dans l’instruction suivante : déterminer si l’expression Expliquer précisément le rôle joué par la valeur de b. 7. Étudier l’instruction : Décomposer son évaluation étape par étape. Indiquer notamment : • la valeur calculée par a – c ; • la nouvelle valeur de d ; • la conversion de cette valeur en bool ; • si ++b est ensuite évalué.
Accueil
Exercices – Partie I – Chapitre 1 8. Étudier maintenant : if (a > 0 && (c = 0)) Expliquer pourquoi l’affectation peut être exécutée alors que le bloc du if ne l’est pas. 9. Expliquer la différence fondamentale entre les deux instructions suivantes : Pourquoi la première est-elle syntaxiquement valide en C++ ? 10. Réécrire les tests du programme sous une forme plus claire, sans modifier son comportement, les affectations volontaires placées dans des conditions devront être rendues aussi explicites 11. Après avoir terminé l’analyse sur papier, compiler et exécuter le programme. Comparer le résultat obtenu avec les valeurs prévues et expliquer toute différence éventuelle.
Accueil
Exercices – Partie I – Chapitre 1 Solution de l’exercice 2 — Le programme qui ne fait pas ce qu’il Les valeurs finales sont : Le programme affiche donc : La difficulté vient du fait que certaines conditions modifient elles-mêmes les variables qu’elles testent. Il faut donc distinguer : • l’évaluation de la condition ; • les modifications produites pendant cette évaluation ; • l’exécution éventuelle du bloc associé au if. Le premier test est : est une affectation et non une comparaison. La valeur de b, soit 0, est affectée à a : L’expression d’affectation possède elle-même la valeur 0. Cette valeur est convertie implicitement en bool : Le bloc n’est donc pas exécuté et c reste égal à 2.
Accueil
Exercices – Partie I – Chapitre 1 Le deuxième test est : if (c > 1 || ++b > 0) La première condition vaut : Elle est donc vraie. L’opérateur || utilise une évaluation en court-circuit autrement dit, dès que son opérande gauche est vrai, l’opérande droit n’est pas évalué, l’expression n’est donc jamais exécutée et la variable b reste égale à 0 puis le bloc du if est exécuté : Le troisième test est : La première expression est simplement : La conversion implicite donne : Avec l’opérateur &&, dès que l’opérande gauche est faux, l’opérande droit n’est pas évalué.
Accueil
Exercices – Partie I – Chapitre 1 n’est donc jamais exécutée. Le bloc du if n’est pas exécuté non plus. Les valeurs restent : Le quatrième test est : On commence par évaluer : L’expression d’affectation vaut elle-même 1. Sa conversion en bool donne : Comme le premier opérande de && est vrai, le second doit également être évalué : La variable b passe de 0 à 1 : La valeur retournée par la pré-incrémentation est donc 1, ce qui est également converti en true. La condition complète est donc vraie et le bloc est exécuté : Les valeurs deviennent :
Accueil
Exercices – Partie I – Chapitre 1 Le cinquième test est : if (a > 0 && (c = 0)) la première condition est vraie et le second opérande de && est donc évalué : La variable c est modifiée et vaut : Mais l’expression d’affectation possède elle-même la valeur 0 donc après sa conversion vers bool : La condition complète est donc fausse et le bloc : n’est pas exécuté. La variable d reste donc égale à 3. Cet exemple montre qu’une condition peut modifier une variable alors que le bloc du if n’est finalement pas exécuté. Le dernier test est : La première expression vaut : Elle est donc convertie en false. Avec l’opérateur ||, lorsque l’opérande gauche est faux, l’opérande droit doit être évalué, on calcule donc :
Accueil
Exercices – Partie I – Chapitre 1 La variable a passe de 3 à 4 : La condition complète vaut donc : false || false = false. n’est pas exécuté. Les valeurs finales sont donc : 8. Tableau récapitulatif 9. Affectation et comparaison ont des significations totalement différentes. L’opérateur = effectue une affectation, dans : la valeur de b est d’abord copiée dans a, puis cette valeur est convertie en bool. L’opérateur ==, en revanche, effectue une comparaison et ne modifie aucune des deux variables. La première écriture est syntaxiquement correcte en C++ parce qu’une expression d’affectation produit elle-même une valeur qui peut être convertie en bool.
Accueil
Exercices – Partie I – Chapitre 1 10. Réécriture explicite des conditions On peut rendre le comportement du programme plus lisible en séparant les affectations et les effets de bord des expressions conditionnelles. Par exemple, le premier test : peut être développé en : Cette seconde écriture est plus longue, mais elle rend explicitement visibles les opérations réellement effectuées et l’ordre dans lequel elles sont évaluées. 11. Réduction au chemin réellement exécuté Pour les valeurs initiales imposées dans cet exercice, on peut aller plus loin et supprimer toutes les branches dont on sait qu’elles ne seront jamais exécutées.
Accueil
Exercices – Partie I – Chapitre 1 std::cout << « a =  » << a << ’\n’; std::cout << « b =  » << b << ’\n’; std::cout << « c =  » << c << ’\n’; std::cout << « d =  » << d << ’\n’; Cette version produit exactement le même résultat pour les valeurs initiales de l’exercice. Elle ne constitue toutefois pas une réécriture générale équivalente du programme original : si les valeurs initiales de a, b, c ou d étaient modifiées, le chemin d’exécution pourrait changer. Cette réduction représente donc uniquement le chemin d’exécution effectivement suivi dans le cas étudié. Cet exercice met en évidence quatre mécanismes importants du C++ : • une affectation est une expression et possède une valeur ; • cette valeur peut être convertie implicitement en bool ; • les opérateurs && et || utilisent une évaluation en court-circuit ; • une expression conditionnelle peut modifier l’état du programme même lorsque le bloc du if n’est finalement pas exécuté. Pour analyser ce type de programme, une bonne méthode consiste à suivre les variables instruction par instruction et à noter séparément les effets produits pendant l’évaluation de chaque condition.
Accueil
Exercices – Partie I – Chapitre 1 Exercice 3 – Représentation des entiers et portabilité On souhaite étudier expérimentalement la taille des principaux types entiers du C++ et déterminer quelles hypothèses peuvent raisonnablement être faites lorsqu’un programme doit être portable. Écrire un programme qui affiche la taille, en octets, des types suivants : • unsigned short ; • unsigned long long ; Les types à taille fixe nécessitent l’en-tête : 1. Écrire le programme permettant d’obtenir les tailles de tous les types précédents à l’aide de l’opérateur sizeof et présenter les résultats sous une forme suffisamment claire pour permettre de comparer facilement les différents types. 2. Quel est le type de la valeur retournée par l’opérateur sizeof ? Expliquer pourquoi ce type est particulièrement adapté pour représenter une taille mémoire. 3. Comparer systématiquement la taille d’un type signé avec celle du type non signé correspondant,
Accueil
Exercices – Partie I – Chapitre 1 Le mot-clé unsigned signifie-t-il que le type occupe davantage de mémoire ? Que constate-t-on concernant leur taille ? Peut-on en déduire que le type char est nécessairement signé ? 5. On exécute le programme sur une machine donnée et on obtient : Peut-on écrire un programme portable en supposant que ces deux résultats seront toujours identiques sur toutes les plateformes ? Justifier la réponse. 6. Comparer maintenant les résultats obtenus pour : Quelle information supplémentaire le nom de ces types fournit-il par rapport aux types short, int, long et long long ? 7. Un programme doit communiquer avec un périphérique dont la documentation impose le format Choisir, parmi les types étudiés, un type adapté à chacune de ces quatre données. Justifier les choix. 8. Un programmeur écrit :
Accueil
Exercices – Partie I – Chapitre 1 unsigned long compteur; parce qu’il souhaite disposer exactement de 32 bits. Expliquer pourquoi ce choix peut poser un problème de portabilité. Proposer un type plus approprié. 9. Considérons maintenant : std::size_t taille = sizeof(long long); Expliquer ce que représente exactement la valeur contenue dans taille. Représente-t-elle : • le nombre de bits ; • le nombre d’octets ; • la valeur maximale pouvant être stockée dans un long long ? 10. Supposons que l’on obtienne : sizeof(uint32_t) = 4 et que le type uint32_t possède exactement 32 bits. Combien de bits contient alors, sur cette plateforme, un octet mesuré par sizeof ? 11. Finalement, classer les affirmations suivantes en trois catégories : garantie par le type, observée sur la machine, (a) sizeof(char) == 1. (b) sizeof(int) == 4. (c) sizeof(long) == 8. (d) sizeof(unsigned int) == sizeof(int). (e) uint32_t représente un entier non signé de 32 bits. (f) unsigned long représente toujours un entier non signé de 32 bits. (g) size_t est destiné notamment à représenter des tailles. Vérification expérimentale Compiler et exécuter le programme. Comparer ensuite les résultats expérimentaux avec les réponses données aux questions précédentes. Il faut distinguer soigneusement ce qui a été observé lors de l’exécution du programme de ce qui peut être supposé lors de l’écriture d’un programme destiné à fonctionner sur une autre plateforme. Solution de l’exercice 3 – Représentation des entiers et portabilité 1. Programme d’expérimentation Les types entiers à largeur fixe sont déclarés dans l’en-tête <cstdint>. On peut utiliser le programme suivant :
Accueil
Exercices – Partie I – Chapitre 1 3 #include <typeinfo> std::cout << « char :  » << sizeof(char) << ’\n’; std::cout << « signed char :  » << sizeof(signed char) << ’\n’; std::cout << « unsigned char :  » << sizeof(unsigned char) << ’\n’; std::cout << « short :  » << sizeof(short) << ’\n’; std::cout << « unsigned short :  » << sizeof(unsigned short) << ’\n’; std::cout << « int :  » << sizeof(int) << ’\n’; std::cout << « unsigned int :  » << sizeof(unsigned int) << ’\n’; std::cout << « long :  » << sizeof(long) << ’\n’; std::cout << « unsigned long :  » << sizeof(unsigned long) << ’\n’; std::cout << « long long :  » << sizeof(long long) << ’\n’; std::cout << « unsigned long long :  » << sizeof(unsigned long long) << ’\n’; std::cout << « std::int8_t :  » << sizeof(std::int8_t) << ’\n’; std::cout << « std::uint8_t :  » << sizeof(std::uint8_t) << ’\n’; std::cout << « std::int16_t :  » << sizeof(std::int16_t) << ’\n’; std::cout << « std::uint16_t :  » << sizeof(std::uint16_t) << ’\n’; std::cout << « std::int32_t :  » << sizeof(std::int32_t) << ’\n’; std::cout << « std::uint32_t :  » << sizeof(std::uint32_t) << ’\n’;
Accueil
std::cout << « std::int64_t :  » << sizeof(std::int64_t) << ’\n’; Exercices – Partie I – Chapitre 1 std::cout << « std::uint64_t :  » << sizeof(std::uint64_t) << ’\n’; std::cout << « std::size_t :  » << sizeof(std::size_t) << ’\n’; Les résultats numériques dépendent en partie de la plateforme sur laquelle le programme est compilé et c’est précisément l’un des objectifs de l’exercice : distinguer ce que l’on observe sur une machine de ce que le C++ permet de garantir. 2. Type retourné par sizeof L’opérateur sizeof retourne une valeur de type : Ce type est un type entier non signé capable de représenter la taille en mémoire d’un objet. On peut donc écrire naturellement : std::size_t taille = sizeof(long long); L’utilisation d’un type non signé est logique puisqu’une taille mémoire ne peut pas être négative. 3. Types signés et non signés Pour un type entier donné, sa variante signée et sa variante non signée occupent la même quantité de mémoire, on a donc notamment : sizeof(short) == sizeof(unsigned short) sizeof(int) == sizeof(unsigned int) sizeof(long) == sizeof(unsigned long) 4 sizeof(long long) == sizeof(unsigned long long) Le mot-clé unsigned ne signifie donc pas que davantage de mémoire est utilisée. Il modifie l’ensemble des valeurs représentables, pour un même nombre de bits, un type signé doit permettre de représenter des valeurs négatives et positives, tandis qu’un type non signé utilise toutes les combinaisons disponibles pour représenter des valeurs positives ou nulles. 4. Le cas particulier de char ont la même taille : car, par définition :
Accueil
Exercices – Partie I – Chapitre 1 et signed char ainsi que unsigned char ont la même taille que char. Il ne faut cependant pas en conclure que char est nécessairement signé, le caractère signé ou non signé de char dépend de l’implémentation, ainsi : sont trois types distincts. 5. Taille de int et de long Supposons que l’expérience donne : On peut affirmer que ces résultats sont vrais sur la plateforme où l’expérience a été effectuée, mais on ne peut pas en déduire qu’ils seront identiques sur toutes les plateformes. Les types fondamentaux : ne doivent donc pas être choisis en supposant arbitrairement une taille précise en bits et c’est une distinction essentielle pour écrire du code portable. 6. Les types à largeur fixe fournit notamment, lorsqu’ils sont disponibles, les types : Le nombre présent dans le nom indique directement le nombre de bits du type, ainsi : est un entier non signé de exactement 32 bits, de même :
Accueil
Exercices – Partie I – Chapitre 1 est un entier signé de exactement 16 bits. Cette propriété constitue leur principal intérêt par rapport aux types fondamentaux lorsque la représentation binaire d’une donnée impose une largeur précise. 7. Choix des types pour un périphérique Le périphérique impose : Dans ce genre de situation, l’utilisation des types à largeur fixe est particulièrement appropriée puisque le format des données est imposé par une spécification extérieure au programme. Ce cas se rencontrera notamment lors de l’étude des microcontrôleurs, des périphériques et des protocoles de communication. 8. Pourquoi unsigned long n’est-il pas un bon choix pour imposer 32 bits ? unsigned long compteur; Cette déclaration garantit que compteur est un entier non signé, mais ne garantit pas qu’il possède exactement 32 bits. Si le format extérieur impose réellement 32 bits, on préférera : std::uint32_t compteur; Le choix du type exprime alors directement la contrainte imposée par le problème. 9. Que contient une variable de type size_t ? Prenons par exemple : std::size_t taille = sizeof(long long); La variable taille contient le nombre d’unités mémoire occupées par un objet de type long long qui sont des octets au sens du C++. Elle ne contient donc ni la valeur maximale pouvant être représentée par un long long, ni directement son nombre de bits, si, sur une machine donnée : sizeof(long long) = 8
Accueil
Exercices – Partie I – Chapitre 1 Supposons que l’expérience donne : sizeof(std::uint32_t) = 4 possède exactement 32 bits. Si ces 32 bits correspondent à 4 unités mesurées par sizeof, on obtient : L’octet de cette plateforme contient donc : Il faut cependant conserver une distinction importante : est garanti par le langage, mais cela ne signifie pas à lui seul que cette unité contient nécessairement 11. Garanties et observations On peut maintenant classer les affirmations proposées. Garantie par le langage Observation possible sur une machine, mais pas garantie générale Observation possible sur une machine, mais pas garantie générale sizeof(unsigned int) == sizeof(int) std::uint32_t représente un entier non signé de unsigned long représente toujours un entier non std::size_t est destiné notamment à représenter Garantie si ce type est fourni par l’implémentation Garantie par son rôle dans la bibliothèque et le langage 12. Expérience complémentaire avec typeid On peut chercher à savoir à quel type fondamental correspond std::size_t sur la plateforme utilisée.
Accueil
Exercices – Partie I – Chapitre 1 std::cout << typeid(std::size_t).name() << ’\n’; Avec Clang sur la machine utilisée pour cet exercice, le programme affiche : Cette lettre est un nom interne utilisé par l’implémentation et dans cet environnement, elle correspond On constate donc expérimentalement que, sur cette plateforme, std::size_t est un alias d’un type entier non signé approprié à l’architecture. Il ne faut cependant pas écrire un programme portable en supposant que std::size_t correspondra toujours à unsigned long, de plus, la chaîne retournée par : dépend de l’implémentation et n’est pas destinée à fournir un nom de type portable et uniformément Cet exercice met en évidence une distinction fondamentale entre deux familles de types entiers. sont des types fondamentaux du langage. Leur taille exacte ne doit pas être déduite uniquement d’une expérience effectuée sur une machine et les types : expriment au contraire explicitement une largeur en bits, lorsqu’ils sont disponibles. Enfin, std::size_t répond à un autre besoin : disposer d’un type entier non signé approprié pour représenter des tailles. Il faut donc toujours distinguer : ce que l’expérience montre sur une machine ce que le programme peut supposer de manière portable.
Accueil
Exercices – Partie I – Chapitre 1 Exercice 4 – Division euclidienne sans flottants On considère deux entiers : On souhaite effectuer la division euclidienne de a par b sans utiliser de nombres flottants. Pour deux entiers positifs, la division euclidienne s’écrit : Les valeurs q et r représentent respectivement le quotient entier et le reste. 1. Écrire un programme qui déclare deux variables entières a et b, puis calcule : • le quotient entier q de la division de a par b ; Le programme ne doit utiliser que des types entiers. déterminer sur papier les valeurs de : Vérifier ensuite le résultat à l’aide du programme. 3. Ajouter au programme une vérification de l’égalité : Le programme devra afficher la valeur de : et permettre de la comparer avec a. 4. Ajouter un test permettant de vérifier que le reste satisfait bien : Afficher un message indiquant si cette condition est satisfaite.
Accueil
Exercices – Partie I – Chapitre 1 5. Modifier les valeurs de a et b et tester notamment les cas suivants : Expliquer ce que deviennent alors le quotient et le reste. 6. Que se passe-t-il si l’on écrit : avec deux variables de type int ? Le résultat contient-il une partie décimale ? Expliquer pourquoi. 7. Comparer les deux expressions suivantes : static_cast<double>(a) / b Quelle différence observe-t-on ? 8. Sans utiliser l’opérateur modulo %, retrouver le reste uniquement à partir de a, b et du quotient Utiliser pour cela la relation : 9. Comparer ensuite les deux calculs du reste :
Accueil
Exercices – Partie I – Chapitre 1 Vérifier qu’ils donnent le même résultat pour plusieurs valeurs positives de a et de b. 10. Ajouter un contrôle empêchant une division par zéro. le programme ne doit effectuer ni division ni modulo et il doit afficher un message d’erreur. 11. Enfin, tester expérimentalement le comportement du programme avec des valeurs négatives, Noter les résultats donnés par les opérateurs : sans chercher pour l’instant à modifier leur comportement. Comparer ces résultats avec la définition mathématique usuelle de la division euclidienne pour les entiers positifs. Pour chaque couple de valeurs positives testé, vérifier simultanément : Le programme devra permettre de distinguer clairement le quotient entier, le reste et la reconstruction
Accueil
Exercices – Partie I – Chapitre 1 Solution de l’exercice 4 – Division euclidienne sans flottants 1. Quotient et reste Pour deux entiers positifs a et b, avec b différent de zéro, la division euclidienne s’écrit : En C++, les opérateurs : permettent respectivement d’obtenir le quotient entier et le reste : Un programme permettant d’effectuer les différents contrôles demandés peut être écrit de la manière std::cout << « Division par zero impossible. » std::cout << « b*q+r =  » std::cout << « q =  » << q << std::endl; std::cout << « r =  » << r << std::endl; std::cout << « rr =  » << rr << std::endl; if (r >= 0 && r < b)
Accueil
Exercices – Partie I – Chapitre 1 std::cout << « Valeurs OK » << std::endl; 3. Vérification de la relation fondamentale Le programme calcule : Avec les valeurs précédentes : Le reste satisfait également : Les deux propriétés caractéristiques de la division euclidienne sont donc vérifiées. 4. Retrouver le reste sans l’opérateur % on peut isoler le reste : On peut donc écrire : r r = 137 − 12 × 11 = 5.
Accueil
Exercices – Partie I – Chapitre 1 Cette égalité constitue également une manière de vérifier expérimentalement le fonctionnement de 5. Quelques cas particuliers Enfin, si a est un multiple exact de b, le reste est également nul. C’est la raison pour laquelle le test du reste doit être : puisque zéro est un reste parfaitement valide.
Accueil
Exercices – Partie I – Chapitre 1 6. Division entière Lorsque les deux opérandes sont de type entier, l’opérateur / effectue une division entière. La valeur mathématique : n’est pas conservée. Le résultat de l’opération est lui-même entier, il ne faut donc pas interpréter cette opération comme une division flottante suivie du stockage de son résultat dans un entier. 7. Forcer une division flottante Pour effectuer une division flottante, on peut convertir explicitement l’un des opérandes : double qq = static_cast<double>(a) / b; Le premier opérande devient alors un double et la division est effectuée en virgule flottante. on obtient approximativement : Il faut cependant faire attention au type de la variable qui reçoit le résultat, l’écriture : int qq = static_cast<double>(a) / b; effectue bien une division flottante, mais son résultat est ensuite converti en int lors de l’affectation et la partie fractionnaire est alors perdue et donc : Pour observer le résultat flottant, il faut donc écrire : double qq = static_cast<double>(a) / b; 8. Protection contre la division par zéro Une division entière par zéro n’est pas une opération valide. Il faut donc vérifier la valeur du diviseur avant d’effectuer les opérations / et % :
Accueil
Exercices – Partie I – Chapitre 1 std::cout << « Division par zero impossible. » Si b vaut zéro, le programme s’arrête avant d’effectuer la division. Dans cet exemple, indique une terminaison provoquée par une situation d’erreur, tandis que : correspond à une terminaison normale du programme. 9. Que se passe-t-il avec des nombres négatifs ? L’expérience devient particulièrement intéressante lorsque l’un ou les deux opérandes sont négatifs. Testons les quatre combinaisons correspondant aux valeurs absolues 137 et 12 on obtient : Dans tous les cas, la relation : reste satisfaite, par exemple, avec : (−12) × 11 + (−5) = −132 − 5 = −137. 10. Division entière et troncature vers zéro Pour les types entiers signés, le quotient produit par la division C++ est tronqué vers zéro, par donne en division entière :
Accueil
Exercices – Partie I – Chapitre 1 137/(−12) = −11 . (−137)/(−12) = 11 . Le reste est ensuite déterminé de manière à conserver la relation : On peut donc toujours le retrouver avec : Les expériences précédentes permettent également de constater que le reste obtenu avec l’opérateur % possède le même signe que le dividende a, sauf naturellement lorsque le reste est nul. Le signe du diviseur ne détermine pas celui du reste : (−137)%(−12) = −5. 12. Division euclidienne et opérateurs C++ Pour les valeurs positives utilisées au début de l’exercice : les opérateurs / et % fournissent directement le quotient et le reste de la division euclidienne : Lorsque des valeurs négatives sont introduites, la règle de troncature vers zéro utilisée par le C++ peut produire un reste négatif et la condition :
Accueil
Exercices – Partie I – Chapitre 1 utilisée au début de l’exercice ne doit donc pas être appliquée directement aux essais comportant des Il faut distinguer la définition mathématique choisie pour une division euclidienne de la définition précise des opérateurs entiers / et % du C++. Cet exercice met en évidence plusieurs propriétés importantes de l’arithmétique entière en C++. Pour deux entiers positifs : Le reste peut également être retrouvé par : constitue le lien fondamental entre les deux opérations. L’exercice montre également qu’une division entre deux entiers reste une division entière. Pour obtenir une division flottante, il faut qu’au moins l’un des opérandes soit de type flottant, par exemple : static_cast<double>(a) / b Enfin, lorsque des nombres négatifs interviennent, il faut tenir compte de la règle de troncature vers zéro utilisée par la division entière du C++ et du signe du reste produit par l’opérateur %.
Accueil
Exercices – Partie I – Chapitre 1 Exercice 5 – Décomposition décimale d’un entier On considère un entier strictement positif n duquel on souhaite analyser l’écriture décimale sans utiliser de chaîne de caractères, de tableau ou de fonction particulière de la bibliothèque standard. Le programme devra travailler uniquement à l’aide d’opérations sur les entiers. 1. Déclarer un entier : puis une seconde variable destinée à conserver la valeur originale de n. Expliquer pourquoi cette seconde variable sera n

Chapitre 2 - Les variables et la mémoire

Chapitre 2 – Les variables et la mémoire

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
2 Les variables et la mémoire 2.1 Prérequis, nouvelles notions et objectifs . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Programme 1 — Variables et mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4 Réponses aux questions de réflexion . . . . . . . . . . . . . . . . . . . . . . . . . . . . Les variables et la mémoire
Accueil
Les variables et la mémoire Prérequis, nouvelles notions et objectifs 2. Types fondamentaux. 1. Adresse mémoire. 3. Le mot clé sizeof. • Qu’est-ce qu’une variable ? • Où est-elle stockée ? • Qu’est-ce qu’une adresse ? • Que signifie &a ? • Combien d’octets occupe un int ? • Les variables sont-elles côte à côte ? • Pourquoi une adresse est-elle écrite en hexadécimal ? • Une adresse est-elle une valeur ? Programme 1 — Variables et mémoire Les variables et la mémoire Code 2.1 – Programme C++ vraiment minimal std::cout << « sizeof(a)=  » << sizeof(a) << ’\n’; Commençons par étudier la première ligne. Lorsque le compilateur voit le code il reconnait un objet de type 1 int, l’identificateur a, l’opérateur d’initialisation = et le littéral La sémantique de cette instruction est : Créer un objet nommé a, de type int, puis l’initialiser avec la valeur entière 10. Pour faciliter la compréhension, nous représenterons la mémoire de la manière suivante : Fig. 2.1 – Schéma. La Figure 2.1 est volontairement très simplifiée. Elle ne représente pas la mémoire réelle de l’ordinateur, mais un modèle qui nous permettra de comprendre progressivement le fonctionnement du Une première remarque importante s’impose. Le nom a n’est pas stocké dans la mémoire de l’ordinateur. Ce nom est uniquement connu du compilateur (et éventuellement du débogueur). En mémoire, il n’existe que des suites de bits représentant les différentes valeurs manipulées par le programme. a donc pour effet de réserver une zone mémoire suffisamment grande pour contenir un objet de type int, puis d’y placer la représentation binaire de la valeur entière 10. Le programme affiche ensuite trois informations : • la valeur de l’objet a, • son adresse mémoire à l’aide de l’opérateur \&, • la taille mémoire occupée par cet objet grâce à l’opérateur sizeof. 1. Le terme objet prendra son sens plus tard.
Accueil
Les variables et la mémoire Une exécution typique peut produire un résultat semblable au suivant : Il est important de comprendre que l’adresse affichée dépend de nombreux facteurs (système d’exploitation, compilateur, options de compilation, etc.). Elle sera donc pratiquement toujours différente d’un ordinateur à l’autre et même d’une exécution à l’autre. Ce qui nous intéresse n’est donc pas la valeur numérique de cette adresse, mais sa signification. L’opérateur \& signifie « donner l’adresse mémoire de l’objet ». Dans notre exemple, l’expression se lit donc simplement : « Adresse mémoire de l’objet a. » Cette notion d’adresse est fondamentale. Toute l’étude des pointeurs reposera sur ce concept. Avant de manipuler des pointeurs, il est donc indispensable de bien comprendre ce que représente réellement une adresse mémoire. • Une variable est un objet possédant un type, une valeur et une adresse mémoire. • Le nom d’une variable n’est pas stocké dans la mémoire ; il est utilisé par le compilateur. • L’opérateur \& permet d’obtenir l’adresse d’un objet. • L’opérateur sizeof indique le nombre d’octets occupés par un objet. • Les adresses affichées lors de l’exécution peuvent varier d’un ordinateur à l’autre. Questions de réflexion 1. Pourquoi une adresse mémoire est-elle généralement affichée en hexadécimal ? 2. Deux variables déclarées successivement sont-elles toujours voisines en mémoire ? 3. La taille d’un int est-elle toujours de quatre octets ? 4. Pourquoi le nom d’une variable n’a-t-il pas besoin d’être conservé en mémoire pendant l’exécution du programme ? Réponses aux questions de réflexion 1. Pourquoi une adresse mémoire est-elle généralement affichée en hexadécimal ? Une adresse mémoire est un nombre entier. Elle pourrait être affichée en décimal, en binaire ou dans toute autre base. La notation hexadécimale est cependant beaucoup plus compacte que l’écriture binaire tout en conservant une correspondance directe avec celle-ci : un chiffre hexadécimal représente exactement quatre bits. Cette représentation est devenue un standard dans la plupart des outils de
Accueil
Les variables et la mémoire 2. Deux variables déclarées successivement sont-elles toujours voisines en mémoire ? Non. Sur de nombreux compilateurs, deux variables locales déclarées successivement sont effectivement placées à des adresses proches. Cependant, le standard C++ ne garantit absolument pas cet ordre. Le compilateur est libre d’organiser la mémoire comme il le souhaite afin d’optimiser le programme. Il ne faut donc jamais écrire un programme en supposant que deux variables seront toujours contiguës 3. La taille d’un int est-elle toujours de quatre octets ? Non. Le standard C++ n’impose pas une taille fixe pour le type int. Il impose uniquement une taille minimale ainsi que des relations entre les différents types entiers. Aujourd’hui, sur les ordinateurs personnels (Windows, Linux, macOS) ainsi que sur les microcontrôleurs ESP32, un int occupe généralement quatre octets. Il ne faut toutefois pas considérer cette valeur comme une propriété du langage. Lorsque la taille doit être parfaitement connue, on utilise plutôt les types à taille fixe comme int32_t ou uint32_t, qui seront étudiés dans l’annexe A. 4. Pourquoi le nom d’une variable n’a-t-il pas besoin d’être conservé en mémoire pendant l’exécution du programme ? Le nom d’une variable est destiné au programmeur et au compilateur. Pendant la compilation, le compilateur remplace les noms symboliques par des adresses mémoire et d’autres informations internes. Une fois le programme compilé, le processeur ne manipule plus des noms tels que a ou compteur. Il travaille uniquement avec des adresses mémoire et des données binaires. Les noms des variables ne sont conservés que si le programme est compilé avec des informations de débogage. Elles permettent alors au débogueur d’afficher les variables avec leur nom d’origine.
Accueil
cpp-Chap2- Les variables et la mémoire
Accueil

Chapitre 3 : Les références

Chapitre 3 – Les références

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
3.1 Prérequis, nouvelles notions et objectifs . . . . . . . . . . . . . . . . . . . . . . . . . . 3.2 Programme 1 — Une référence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3 Une référence est un alias . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5 Les références constantes (const) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6 Pourquoi les références existent-elles ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.9 Réponses aux questions de réflexion . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
Prérequis, nouvelles notions et objectifs 2. Adresse mémoire. 3. Référence constante (const). • Qu’est-ce qu’une référence ? • Pourquoi parle-t-on d’alias ? • Une référence possède-t-elle sa propre mémoire ? • Une référence possède-t-elle une adresse ? • Peut-on modifier la variable originale via une référence ? • Une référence peut-elle être réaffectée ? • À quoi sert une référence constante ? Programme 1 — Une référence Code 3.1 – Référence std::cout << « Etat initial\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; std::cout << « \nApres : a = 15\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; std::cout << « \nApres : r = 42\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; Une référence est un alias Une référence ne “prend” pas la valeur d’une variable. Elle devient un second nom désignant exactement le même objet.
Accueil
Référence et mémoire Fig. 3.1 – Illustration au niveau de la mémoire. Une seule case mémoire est partagée par les deux identificateurs. Les références constantes (const) Reprenons le programme ci-dessous et déclarons la référence comme étant constante. std::cout << « Etat initial\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; std::cout << « \nApres : a = 15\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; // Erreur de compilation std::cout << « \nApres : r = 42\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; Le résultat nous montre qu’il est impossible de changer la valeur de r. La référence ne peut pas être affectée à une autre valeur. Le compilateur n’accepte pas l’instruction d’affectation. Pourquoi les références existent-elles ? Les références permettent de donner un second nom à un objet existant, sans effectuer de copie. Lorsqu’une référence est déclarée constante, elle ne permet que la lecture de l’objet auquel elle est liée. Cette propriété est particulièrement utile lors de l’appel des fonctions : la fonction peut accéder directement à l’objet original sans en faire une copie, tout en étant incapable de le modifier. Mais alors que se passe t’il si on affecte une autre valeur à a après coup, que va devenir r ?
Accueil
std::cout << « Etat initial\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; std::cout << « \nApres : a = 15\n »; std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; Le résultat après compilation et exécution est : Code 3.3 – Programme C++ vraiment minimal Donc le programme s’exécute sans erreur. En résumé, une référence constante interdit uniquement les modifications effectuées à travers cette référence. Elle ne rend pas l’objet lui-même constant. Ainsi, dans notre exemple, l’instruction est parfaitement autorisée. En revanche, l’instruction est refusée par le compilateur, car elle tenterait de modifier l’objet au moyen d’une référence déclarée • Une référence est un second nom donné à un objet existant. • Une référence doit être initialisée lors de sa création. • Une référence ne peut pas être réaffectée à un autre objet. • Une référence constante interdit les modifications effectuées à travers cette référence. • Une référence constante ne rend pas l’objet lui-même constant.
Accueil
Questions de réflexion 1. Une référence occupe-t-elle de la mémoire ? 2. Peut-on créer une référence sans objet ? 3. Peut-on faire en sorte qu’une référence désigne ensuite un autre objet ? 4. Quelle est la différence entre une référence et un pointeur ? Réponses aux questions de réflexion 1. Une référence occupe-t-elle de la mémoire ? Réponse : Non de code l’adresse de la référence sur une valeur. Demandons à un petit bout Code 3.4 – Adresse d’une référence std::cout << « &a =  » << &a << ’\n’; std::cout << « &r =  » << &r << ’\n’; Code 3.5 – Sortie. Les deux adresses sont identiques. Une référence ne désigne pas un nouvel objet ; demander son adresse revient donc à demander l’adresse de l’objet auquel elle est liée. Conceptuellement, une référence n’est pas un nouvel objet : c’est un alias. Son implémentation est laissée au compilateur, qui est libre d’utiliser ou non de la mémoire supplémentaire. 2.Peut-on créer une référence sans objet ? Réponse : Non un objet déjà existant. Une référence sur un objet nécessite 3. Peut-on faire en sorte qu’une référence désigne ensuite un autre objet ? Réponse : Non Code 3.6 – Programme C++ vraiment minimal
Accueil
std::cout << « a =  » << a << ’\n’; std::cout << « r =  » << r << ’\n’; dont la sortie est : Code 3.7 – Programme C++ vraiment minimal On se dit que oui, puisque r=20, mais ce n’est pas le cas ! C’est la valeur de a que r a transformé lors de l’affectation r = b;. C’est a qui vaut 20 à présent car r lui a affecté la valeur de b. r fait toujours référence à a. 4. Quelle est la différence entre une référence et un pointeur ? Une référence est un second nom donné à un objet existant. Une fois créée, elle reste liée à cet objet pendant toute sa durée de vie. Elle ne peut jamais être réaffectée à un autre objet et doit obligatoirement être initialisée lors de Un pointeur est, quant à lui, une variable dont la valeur est une adresse mémoire. Il peut être modifié pour désigner successivement plusieurs objets différents et peut également ne désigner aucun objet Les deux notions permettent donc d’accéder indirectement à un objet, mais leur philosophie est très différente. Une référence représente un alias permanent, alors qu’un pointeur représente une adresse pouvant être manipulée. Le chapitre suivant sera entièrement consacré aux pointeurs et montrera pourquoi ils constituent l’un des mécanismes les plus puissants du langage C++. Est un alias d’un objet existant. Est une variable contenant une Doit être initialisée lors de sa déclaration. Peut être initialisé plus tard (ce qui est généralement déconseillé). Ne peut jamais être réaffectée à un Peut désigner successivement plusieurs objets. Ne peut pas être nulle. Peut valoir nullptr. S’utilise comme l’objet lui-même. Nécessite un déréférencement (∗) pour accéder à l’objet pointé. Table 3.1 – Comparaison entre une référence et un pointeur.
Accueil
Accueil
cpp-Chap3- Les références
Accueil

Chapitre 4 : Les pointeurs

Chapitre 4 – Les pointeurs

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
4.1 Prérequis, nouvelles notions et objectifs . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2 Programme 1 — Une adresse est une valeur . . . . . . . . . . . . . . . . . . . . . . . 4.4.1 Modifier une variable à travers un pointeur . . . . . . . . . . . . . . . . . . . . 4.4.2 Un pointeur est une variable . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.9 Réponses aux questions de réflexion . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
Prérequis, nouvelles notions et objectifs 2. Adresse mémoire. 2. Opérateur de déréférencement (∗). 3. Pointeur nul (nullptr). • Qu’est-ce qu’un pointeur ? • Que contient un pointeur ? • Quelle est la différence entre une valeur et une adresse ? • Que signifie l’opérateur ∗ ? • Que signifie l’opérateur & ? • Qu’est-ce que le déréférencement ? • À quoi sert un pointeur ? Programme 1 — Une adresse est une valeur Code 4.1 – Pointeur Exp 1 std::cout << « a =  » << a << ’\n’; std::cout << « &a =  » << &a << ’\n’; std::cout << ’\n’; Code 4.2 – Sortie du programme Que s’est-il passé ? Dans le chapitre précédent, nous avons vu qu’une variable possède une adresse mémoire. Par exemple, si l’on écrit : l’objet a possède la valeur 10 mais également une adresse mémoire que l’on peut faire imprimer avec std::cout << &a << \n; Cette valeur est une adresse mémoire. Jusqu’à présent, nous nous sommes contentés de l’afficher. Mais une question se pose naturellement : Peut-on conserver cette adresse dans une variable ? La réponse est oui. C’est précisément le rôle des pointeurs. Qu’est-ce qu’un pointeur ? Un pointeur est une variable dont la valeur est une adresse mémoire. La déclaration du pointeur p qui prend comme valeur l’adresse de a se fait par int∗ signifie je déclare une variable capable de contenir l’adresse d’un objet de type int. p est le nom de cette variable qui est initialisée avec la valeur &a qui est l’adresse mémoire de a.
Accueil
Pourquoi écrit-on int∗ p et non int ∗p ? Les deux notations sont équivalentes et le compilateur ne fait aucune différence. Dans ce cours, j’utiliserai de préférence l’écriture int ∗p, voici pourquoi. Soient les déclarations : Les deux sont acceptées par le compilateur. Je préfère la seconde car elle dit : On travaille avec des objets qui sont de type entiers. On définit un pointeur ∗p sur un entier qui recevra une adresse et une variable q qui recevra elle une valeur entière. La première notation, int∗ p,q; peut sembler vouloir dire que l’on déclare deux pointeurs ∗p et ∗q ce qui bien sûr n’est pas le cas. Finalement le mieux est de donner ces déclarations sur deux lignes, tout le monde est alors content : Le déréférencement Code 4.3 – Déréférencement //ou encore int* p = &a Code 4.4 – Programme C++ vraiment minimal
Accueil
Nous savons maintenant que la variable p contient l’adresse mémoire de l’objet a. Cependant, dans de nombreuses situations, ce n’est pas l’adresse qui nous intéresse mais la valeur stockée à cette Pour accéder à cette valeur, le langage C++ utilise l’opérateur de déréférencement ∗. « la valeur contenue à l’adresse mémorisée dans le pointeur p ». Autrement dit, le compilateur suit l’adresse contenue dans le pointeur puis accède à l’objet situé à Modifier une variable à travers un pointeur Code 4.5 – Programme C++ vraiment minimal std::cout << « Avant\n »; std::cout << « a =  » << a << ’\n’; std::cout << « \nApres\n »; std::cout << « a =  » << a << ’\n’; Le pointeur ne permet pas seulement de lire la valeur située à une adresse ; il permet également de la modifier.
Accueil
Un pointeur est une variable Un pointeur étant une variable, sa valeur peut varier ! Code 4.6 – Un pointeur peut varier std::cout << *p << ’\n’; p = &b; // On affecte une autre adresse a p std::cout << *p << ’\n’; Contrairement à une référence, un pointeur peut être réaffecté. On remarque dans cet exemple que la forme de déclaration est peut-être plus logique que vu que lors de la réaffectation on écrit Un poonteur peut exister sans désigner d’objet. C’est justement une différence avec les références. On définit le pointeur nul avec la commande :
Accueil
n’a aucun sens, Il tente d’accéder un objet qui n’exsiste pas. Pointeurs et mémoire Code 4.7 – Programme C++ vraiment minimal On peut représenter ces deux déclaration par le dessin suivant : Fig. 4.1 – Pointeurs et mémoire Les flèches représentent des valeurs. La case mémoire de p contient simplement le nombre 0x1000 qui est interprété comme une adresse. Il faut retenir ce tableau résumant la différence entre les pointeurs et les références : Toujours liée à un objet Peut être nul (nullptr) Alias d’un objet existant Variable contenant une adresse Table 4.1 – Comparaison entre une référence et un pointeur. L’opérateur ∗ possède deux significations différentes en C++. Lors d’une déclaration, il indique que la variable est un pointeur. En dehors d’une déclaration, il représente l’opérateur de déréférencement.
Accueil
• Un pointeur est une variable dont la valeur est une adresse mémoire. • L’opérateur & permet d’obtenir l’adresse d’un objet. • L’opérateur ∗ permet d’accéder à l’objet situé à l’adresse contenue dans un pointeur. • Un pointeur possède sa propre case mémoire et donc sa propre adresse. • Un pointeur peut être réaffecté pour désigner un autre objet. • Un pointeur peut être nul (nullptr) et ne désigner alors aucun objet. Questions de réflexion 1. Quelle est la différence entre une valeur et une adresse mémoire ? 2. Pourquoi dit-on qu’un pointeur est une variable comme les autres ? 3. Quelle est la différence entre p, ∗p et &p ? 4. Pourquoi est-il dangereux de déréférencer un pointeur nul ? 5. Quelle est la principale différence entre une référence et un pointeur ? Réponses aux questions de réflexion 1. Une valeur représente une information (par exemple un entier ou un caractère), tandis qu’une adresse mémoire indique l’endroit où cette information est stockée dans la mémoire de l’ordinateur. 2. Comme toute variable, un pointeur possède sa propre case mémoire, sa propre adresse et une durée de vie. Sa particularité est que la valeur qu’il contient est une adresse mémoire. 3. Le symbole p représente le pointeur lui-même, c’est-à-dire la variable qui contient une adresse. L’expression ∗p représente l’objet situé à cette adresse. L’expression &p représente l’adresse mémoire du pointeur lui-même. 4. Un pointeur nul ne désigne aucun objet. Tenter d’accéder à l’objet qu’il est censé désigner conduit à un comportement indéfini. Il est donc indispensable de vérifier qu’un pointeur est valide avant de le déréférencer. 5. Une référence est un alias d’un objet existant. Elle doit être initialisée dès sa création et ne peut jamais être réaffectée. Un pointeur est une variable contenant une adresse mémoire. Il peut être réaffecté pour désigner successivement plusieurs objets et peut également ne désigner aucun objet (nullptr). Remarque 4.9.1. Nous savons maintenant manipuler un pointeur désignant un objet unique. Le chapitre suivant montrera que plusieurs objets peuvent être stockés côte à côte en mémoire. Nous découvrirons alors les tableaux et comprendrons pourquoi ils sont si étroitement liés aux pointeurs.
Accueil
Accueil
cpp-Chap4- Les pointeurs
Accueil

Chapitre 5 - Les fonctions

Chapitre 5 – Les fonctions

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
5.8 Une fonction peut être appelée plusieurs fois . . . . . . . . . . . . . . . . . . . . . . . 5.9 Les fonctions communiquent avec le programme . . . . . . . . . . . . . . . . . . . . . 5.12 Les fonctions peuvent recevoir plusieurs paramètres . . . . . . . . . . . . . . . . . . . 5.13 Les fonctions qui retournent une valeur . . . . . . . . . . . . . . . . . . . . . . . . . . 5.15 Le passage par référence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.17 Les prototypes de fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.17.1 Pourquoi utiliser des prototypes ? . . . . . . . . . . . . . . . . . . . . . . . . . 5.17.3 Qu’est-ce qu’un prototype ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.17.4 Les noms des paramètres sont-ils obligatoires ? . . . . . . . . . . . . . . . . . . 5.18 Les paramètres par défaut . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.18.1 Pourquoi utiliser des paramètres par défaut ? . . . . . . . . . . . . . . . . . . . 5.18.3 Comment cela fonctionne-t-il ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.18.4 Plusieurs paramètres . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.18.5 Une règle importante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.19 La surcharge des fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.19.1 Pourquoi surcharger une fonction ? . . . . . . . . . . . . . . . . . . . . . . . . . 5.19.3 Comment le compilateur choisit-il ? . . . . . . . . . . . . . . . . . . . . . . . . 5.19.4 Les paramètres doivent être différents . . . . . . . . . . . . . . . . . . . . . . . 5.19.5 Surcharge et paramètres par défaut . . . . . . . . . . . . . . . . . . . . . . . . 5.19.6 Autre exemple de surcharge . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
• Comprendre le rôle d’une fonction. • Déclarer et définir une fonction. • Appeler une fonction. • Comprendre les paramètres et les arguments. • Différencier passage par valeur, par référence et par pointeur. • Comprendre l’instruction return. • Écrire des fonctions qui retournent une valeur. • Décomposer un programme en fonctions simples. 4. Instructions conditionnelles 7. Appel de fonction Une fonction est un bloc d’instructions auquel on attribue un nom afin de pouvoir l’utiliser plusieurs fois dans un programme. Au lieu d’écrire plusieurs fois le même morceau de code, on l’écrit une seule fois dans une fonction puis on appelle cette fonction chaque fois que cela est nécessaire. Les fonctions permettent notamment : • d’éviter les répétitions ; • de rendre un programme plus lisible ; • de faciliter les tests et le débogage ; • de construire progressivement des programmes de grande taille. Le langage C++, comme la plupart des langages modernes, est largement fondé sur cette idée de décomposition en fonctions. Pourquoi les fonctions ? Jusqu’à présent, tous nos programmes étaient relativement courts et tenaient entièrement dans la fonction main(). Cette approche est parfaitement adaptée pour découvrir les différents éléments du langage C++, mais elle atteint rapidement ses limites dès que les programmes deviennent plus Considérons par exemple un programme qui doit gérer les notes d’une classe. Celui-ci pourrait être amené à effectuer les opérations suivantes : • lire les notes des étudiants ; • calculer la moyenne de la classe ; • déterminer la meilleure note ; • afficher les résultats à l’écran ; • enregistrer les données dans un fichier. Écrire toutes ces opérations directement dans la fonction main() rendrait le programme difficile à lire, à comprendre et à modifier. Plus le programme grandit, plus cette difficulté devient importante. Une solution beaucoup plus efficace consiste à décomposer le problème en plusieurs tâches plus simples. Chaque tâche est alors regroupée dans une fonction qui possède un rôle bien précis.
Accueil
• une fonction lit les notes ; • une fonction calcule la moyenne ; • une fonction recherche la meilleure note ; • une fonction affiche les résultats ; • une fonction enregistre les données. Le programme principal devient alors beaucoup plus clair. Il ressemble davantage à une suite d’actions qu’à une longue liste d’instructions. double moyenne = calculerMoyenne(); afficherResultats(moyenne); enregistrerDonnees(); À ce stade, peu importe la manière dont ces fonctions sont écrites. Ce qui est important est de comprendre que chacune d’elles réalise une tâche précise. Cette manière de programmer présente de nombreux avantages : • le programme est plus facile à lire ; • les différentes parties peuvent être testées séparément ; • une modification dans une fonction n’affecte généralement pas les autres ; • une même fonction peut être utilisée plusieurs fois dans un programme. Une fonction peut donc être considérée comme un outil spécialisé auquel on confie une tâche bien définie. En réunissant plusieurs fonctions, il devient possible de construire des programmes beaucoup plus importants tout en conservant une structure claire et facile à maintenir. Une première fonction Nous savons maintenant pourquoi les fonctions sont utiles. Voyons à présent comment en écrire une. La plus petite fonction possible est constituée de trois éléments : • une paire de parenthèses ; • un bloc d’instructions délimité par des accolades. En voici un exemple :
Accueil
À première vue, cette fonction semble ne rien faire. Pourtant, elle est parfaitement valide. Nous venons de définir une fonction nommée bonjour. Examinons chacun des éléments qui la composent. • void indique que la fonction ne retourne aucune valeur. • bonjour est le nom de la fonction. • () indique que la fonction ne reçoit, pour l’instant, aucun paramètre. • {} délimite le corps de la fonction, c’est-à-dire l’ensemble des instructions qui seront exécutées lorsque cette fonction sera appelée. Pour le moment, le corps de la fonction est vide. Nous ajouterons des instructions dans les sections Il est important de comprendre qu’une fonction n’est pas exécutée au moment où elle est définie. Sa définition indique simplement au compilateur qu’une nouvelle fonction existe. Pour qu’elle soit exécutée, il faudra l’appeler explicitement depuis une autre partie du programme. Où placer les fonctions ? Dans les exemples de ce chapitre, les fonctions seront définies avant la fonction main(). std::cout << « Bonjour ! » << std::endl; Cette organisation présente un avantage important : lorsque le compilateur lit le programme de haut en bas, il connaît déjà la fonction bonjour() lorsqu’il rencontre son appel dans la fonction main(). Plus loin dans ce chapitre, nous découvrirons qu’il existe une autre manière d’organiser un programme. Pour l’instant, nous placerons donc toujours les fonctions avant main(). Appeler une fonction Définir une fonction ne signifie pas qu’elle sera exécutée automatiquement. Une fonction est simplement mise à la disposition du programme. Pour qu’elle soit exécutée, il faut l’appeler. Complétons notre première fonction en lui ajoutant une instruction. std::cout << « Bonjour ! » << std::endl;
Accueil
Cette fonction est maintenant capable d’afficher le message Bonjour !. Cependant, si nous écrivons simplement cette fonction dans un programme, rien ne sera affiché. Considérons le programme suivant. std::cout << « Bonjour ! » << std::endl; Lors de son exécution, ce programme n’affiche absolument rien. Pourquoi ? Tout simplement parce que la fonction bonjour() n’a jamais été appelée. Pour exécuter cette fonction, il suffit d’écrire son nom suivi d’une paire de parenthèses. std::cout << « Bonjour ! » << std::endl; Cette fois, le programme affiche : est appelée un appel de fonction. Lorsque le programme rencontre cette instruction, il interrompt momentanément l’exécution de main(), exécute les instructions contenues dans la fonction bonjour(), puis revient automatiquement à l’instruction suivante.
Accueil
Fig. 5.1 – Chronologie de l’appel de la fonction bonjour() ci-dessous. • Le programme commence sont exécution dans la fonction main() • Lorsqu’il rencontre l’appel de la fonction bonjour();, il saute vers la définition de la fonction • Il exécute les instructions contenues dans la fonction, càd qu’il affiche Bonjour ! et • il revient dans main() pour poursuivre son exécution. Ici, le programme se termine avec l’instruction return 0 ;. Une fonction peut être appelée plusieurs fois L’un des principaux intérêts d’une fonction est qu’elle peut être utilisée autant de fois que nécessaire. Considérons le programme suivant. std::cout << « Bonjour ! » << std::endl;
Accueil
L’exécution du programme produit le résultat suivant. La fonction bonjour() n’a été écrite qu’une seule fois, mais elle a été exécutée trois fois. Chaque appel provoque une nouvelle exécution de toutes les instructions contenues dans la fonction. Une fonction peut être appelée une seule fois, plusieurs fois ou même jamais. Tout dépend des besoins • Une fonction est définie une seule fois, mais elle peut être appelée autant de fois que • Chaque appel exécute de nouveau toutes les instructions contenues dans cette fonction. Que va faire le compilateur dans ce cas ? Si le programme s’exécute, que va-t-il se passer ? Quelle va être la sortie ? std::cout << « Bonjour ! » << std::endl; On remarquera que la fonction bonjour() est appelée à l’intérieure d’elle-même ! Les fonctions communiquent avec le programme Notre fonction bonjour() présente toutefois une limitation importante : elle affiche toujours le même
Accueil
std::cout << « Bonjour ! » << std::endl; Quel que soit le nombre de fois où cette fonction est appelée, le résultat reste toujours identique. Supposons maintenant que nous souhaitions afficher plusieurs messages différents. Notre fonction actuelle ne permet pas de réaliser cette tâche puisqu’elle ne possède aucun moyen de savoir quel prénom doit être affiché. Pour rendre une fonction plus générale, il faut pouvoir lui transmettre des informations au moment Autrement dit, la fonction doit pouvoir communiquer avec le reste du programme. Dans la section suivante, nous allons découvrir le mécanisme utilisé en C++ pour transmettre ces informations à une fonction. Le programme ci-dessous compile parfaitement. Comment pourriez-vous le modifier afin qu’il affiche successivement sans écrire trois fonctions différentes ? std::cout << « Bonjour ! » << std::endl; Dans la section précédente, nous avons constaté que notre fonction bonjour() affichait toujours le même message. Pour la rendre plus générale, nous devons pouvoir lui transmettre une information : le prénom de la personne à saluer.
Accueil
En C++, les informations reçues par une fonction sont appelées des paramètres. Modifions notre fonction de la manière suivante. void bonjour(std::string prenom) std::cout << « Bonjour  » <<  » ! » << std::endl; La différence avec la version précédente est la présence de entre les parenthèses. Cette déclaration indique que la fonction reçoit une chaîne de caractères nommée prenom. Cette information pourra être utilisée à l’intérieur de la fonction comme n’importe quelle autre variable. Dans notre exemple, le paramètre est utilisé pour compléter le message affiché. Une fonction peut recevoir aucun, un ou plusieurs paramètres. Nous étudierons les fonctions possédant plusieurs paramètres un peu plus loin dans ce chapitre. Fig. 5.2 – Paramètre. Déclarer un paramètre dans une fonction ne suffit pas. Il faut également lui fournir une valeur au moment où la fonction est appelée. Cette valeur est appelée un argument. Considérons le programme suivant. void bonjour(std::string prenom) std::cout << « Bonjour  » <<  » ! » << std::endl;
Accueil
L’exécution du programme produit le résultat suivant : À chaque appel de la fonction, une nouvelle valeur est transmise au paramètre prenom. Ainsi, • lors de l’appel bonjour(« Michel »);, le paramètre prenom reçoit la valeur « Michel » ; • lors de l’appel bonjour(« Alice »);, il reçoit la valeur « Alice » ; • lors de l’appel bonjour(« Bob »);, il reçoit la valeur « Bob ». Les paramètres rendent les fonctions beaucoup plus générales. Une seule fonction peut alors effectuer la même opération sur des données différentes simplement en recevant des arguments différents. Fig. 5.3 – Schéma. Lien avec les mathématiques : En mathématiques, on parle très souvent de l’argument d’une fonction, par exemple dans f(x). En programmation, on distingue le paramètre, qui apparaît dans la définition de la fonction, de l’argument, qui est la valeur transmise lors de son appel. Cette distinction permet de décrire plus précisément le fonctionnement d’un programme. Les fonctions peuvent recevoir plusieurs paramètres Jusqu’à présent, les fonctions que nous avons écrites ne recevaient qu’une seule information. Il est toutefois très fréquent qu’une fonction ait besoin de plusieurs données pour effectuer son travail. Considérons, par exemple, une fonction qui calcule l’aire d’un rectangle. Soit le programme complet avec la fonction void aireRectangle(double largeur, double longueur) qui calcule et imprime l’aire du rectangle.
Accueil
void aireRectangle(double largeur, double longueur){ std::cout << « L’aire du rectangle est :  » << largeur∗longueur << std::endl; int main(int argc, const char ∗ argv[]) { aireRectangle(5.0,8.0); Cette fonction possède deux paramètres : Les paramètres sont séparés par des virgules et sont déclarés entre les parenthèses de la fonction. Ils peuvent être de même type, comme dans cet exemple, ou de types différents. void afficherPersonne(std::string nom, int age) <<  » ans. » << std::endl; Le nombre de paramètres n’est pas limité à deux. Une fonction peut recevoir autant de paramètres que nécessaire. Il est cependant recommandé de ne pas en utiliser un trop grand nombre afin de conserver un programme clair et facile à comprendre. Cependant la fonction qui calcule l’aire du rectangle n’est pas satisfaisante car normalement une fonction mathématique retourne une valeur lorsqu’on lui a donné un argument. Notre fonction void aireRectangle(double largeur, double longueur) calcule l’aire certes, mais elle renvoie également une phrase avec la valeur. Cette valeur ne peut pas être réutilisée par le programme. Si l’on souhaite effectuer d’autres calculs avec cette aire, il faut la recalculer, ce qui est peu pratique. Les fonctions qui retournent une valeur Dans la section précédente, notre fonction calculait correctement l’aire d’un rectangle, mais elle affichait directement le résultat à l’écran. Cette solution présente un inconvénient important : une fois la valeur affichée, le programme ne peut plus la réutiliser. Par exemple, il est impossible d’effectuer un autre calcul avec cette aire ou de la mémoriser dans une variable. Il est donc préférable que la fonction renvoie le résultat au programme appelant, qui décidera lui-même de son utilisation. Pour cela, il suffit de remplacer le mot-clé void par le type de la valeur que la fonction doit retourner. Notre fonction devient alors :
Accueil
double aireRectangle(double largeur, double longueur) return largeur ∗ longueur; Le type double indique que cette fonction retourne un nombre réel. Le mot-clé return termine l’exécution de la fonction et renvoie immédiatement la valeur qui le suit au programme appelant. Dans notre exemple, la valeur retournée est le produit largeur ∗ longueur La fonction ne réalise plus aucun affichage. Elle se contente d’effectuer le calcul et de renvoyer le double aireRectangle(double largeur, double longueur) return largeur ∗ longueur; double aire = aireRectangle(5.0, 8.0); std::cout << « L’aire vaut  » La valeur retournée par la fonction est ici mémorisée dans la variable aire. Elle pourra ensuite être affichée, utilisée dans un autre calcul ou comparée à une autre valeur. Il est également possible de ne pas utiliser la variable intermédiaire aire et d’écrire simplement double aireRectangle(double largeur, double longueur) return largeur ∗ longueur; std::cout << « L’aire vaut  » << aireRectangle(5.0, 8.0) << std::endl;
Accueil
void doubler(int x) { std::cout << a << « \n »; La réponse est 5, pourquoi ? Le passage par valeur Par défaut, les paramètres d’une fonction sont transmis par valeur. Cela signifie que la fonction reçoit une copie de chaque argument. Considérons le programme suivant. std::cout << a << std::endl; À première vue, on pourrait penser que la variable a vaudra 10 après l’appel de la fonction. Pourtant, ce programme affiche Lorsque la fonction est appelée, la valeur de a est copiée dans le paramètre x. La fonction travaille donc sur une nouvelle variable indépendante de la variable d’origine, et cela
Accueil
std::cout << a << std::endl; Les deux variables nommées a sont distinctes. Bien qu’elles portent le même nom, les deux variables a sont totalement indépendantes. Chacune possède son propre emplacement en mémoire. Ce mode de transmission est appelé passage par valeur. • Le passage par valeur protège les variables du programme appelant contre toute modification involontaire. Cependant, il existe des situations où l’on souhaite justement modifier la variable transmise à la fonction. Nous allons voir dans la section suivante Le passage par référence Dans la section précédente, nous avons vu que le passage par valeur protège la variable d’origine en travaillant sur une copie. Il existe cependant des situations où l’on souhaite que la fonction puisse modifier directement la variable transmise. Pour cela, il suffit de déclarer le paramètre comme une référence. void doubler(int& a) std::cout << a << std::endl;
Accueil
Cette fois, le programme affiche La seule différence avec l’exemple précédent est la présence du symbole & devant le paramètre. Ce symbole indique que le paramètre est une référence sur la variable transmise lors de l’appel de la La fonction ne travaille donc plus sur une copie, mais directement sur la variable d’origine. Toute modification du paramètre est immédiatement visible dans le programme appelant. • Le passage par référence est particulièrement utile lorsqu’une fonction doit modifier une ou plusieurs variables du programme appelant. Il évite également la création d’une copie, ce qui peut améliorer les performances pour les objets volumineux. Les références constituent aujourd’hui la méthode la plus simple et la plus sûre pour permettre à une fonction de modifier une variable. Il existe toutefois une autre technique reposant sur les pointeurs. Bien qu’elle soit un peu plus complexe, elle reste très utilisée, notamment dans certaines bibliothèques et dans les programmes proches du matériel. Le passage par pointeur Comme les références, les pointeurs permettent à une fonction de modifier directement une variable du programme appelant. La différence est que la fonction ne reçoit plus la variable elle-même, mais son adresse mémoire. Considérons le programme suivant. void doubler(int∗ a) std::cout << a << std::endl; L’exécution de ce programme affiche La fonction modifie donc directement la variable a du programme appelant.
Accueil
À première vue, l’utilisation du symbole * peut sembler déroutante puisqu’il apparaît à deux endroits void doubler(int∗ a) Pourtant, ces deux symboles n’ont pas la même signification. Dans la déclaration le symbole * indique simplement que le paramètre a est un pointeur vers un entier. En revanche, dans l’instruction le symbole * signifie : « utiliser la valeur située à l’adresse contenue dans le pointeur ». On dit que le pointeur est déréférencé. Le passage par pointeur fait intervenir deux opérateurs déjà rencontrés dans les chapitres précédents. • &a signifie : « transmettre l’adresse de la variable a » ; • int* a signifie : « recevoir cette adresse dans un pointeur » ; • *a signifie : « accéder à la valeur située à cette adresse ». Ces trois écritures sont donc intimement liées. p est un pointeur vers un entier valeur située à l’adresse contenue dans p Les passages par référence et par pointeur permettent tous deux de modifier la variable d’origine. En C++, on privilégie généralement les références lorsque cela est possible, car leur syntaxe est plus simple et moins sujette aux erreurs. Les pointeurs restent néanmoins indispensables dans de nombreuses situations que nous rencontrerons plus loin dans cet ouvrage. Les prototypes de fonctions Pourquoi utiliser des prototypes ? Jusqu’à présent, toutes les fonctions ont été définies avant la fonction main(). Cette organisation fonctionne parfaitement pour les petits programmes.
Accueil
Lorsque les programmes deviennent plus importants, il est souvent préférable de regrouper les définitions des fonctions à la fin du fichier afin que la fonction main() apparaisse immédiatement. Le programme est alors plus facile à lire et sa structure générale est visible dès les premières lignes. Cependant, lorsqu’il rencontre un appel de fonction, le compilateur doit déjà connaître son existence. Les prototypes permettent de résoudre ce problème. Le programme suivant ne compile pas. std::cout << « Bonjour ! » << std::endl; Au moment où le compilateur rencontre l’appel à bonjour(), il ne connaît pas encore cette fonction. Il suffit alors d’ajouter son prototype avant main(). std::cout << « Bonjour ! » << std::endl; Le compilateur connaît maintenant l’existence de la fonction bonjour() avant son appel. Il peut donc vérifier que cet appel est correct et poursuivre la compilation du programme. Qu’est-ce qu’un prototype ? Un prototype est une déclaration qui indique au compilateur qu’une fonction existe. Il précise son nom, son type de retour et la liste de ses paramètres, mais ne contient pas le corps de la fonction. Contrairement à une définition de fonction, un prototype se termine toujours par un point-virgule.
Accueil
Les noms des paramètres sont-ils obligatoires ? Considérons le prototype suivant. double aireRectangle(double largeur, double longueur); La définition de la fonction peut utiliser d’autres noms de paramètres. double aireRectangle(double a, double b) Le programme compile pourtant parfaitement. En effet, le compilateur ne tient compte que du type de retour et des types des paramètres. Les noms des paramètres servent uniquement à rendre le code plus facile à comprendre. C’est d’ailleurs pour cette raison que le prototype peut également s’écrire de la manière suivante. double aireRectangle(double, double); Cette écriture est parfaitement correcte. En pratique, on préfère cependant conserver les noms des paramètres afin de rendre le code plus lisible. • Les prototypes permettent de séparer la déclaration d’une fonction de sa définition. Ils rendent les programmes plus faciles à organiser tout en permettant au compilateur de connaître l’existence des fonctions avant leur utilisation. Dans les projets de grande taille, les prototypes sont généralement placés dans des fichiers d’en-tête (.h ou .hpp), tandis que les définitions des fonctions sont regroupées dans des fichiers sources (.cpp). Nous découvrirons cette organisation dans un chapitre ultérieur. Les paramètres par défaut Pourquoi utiliser des paramètres par défaut ? Il arrive qu’une fonction soit appelée très souvent avec la même valeur pour l’un de ses paramètres. Par exemple, une fonction peut calculer une puissance. Dans la majorité des cas, nous souhaitons simplement calculer le carré d’un nombre. Il serait alors dommage de devoir préciser systématiquement que l’exposant vaut 2. Les paramètres par défaut permettent de définir une valeur qui sera utilisée automatiquement lorsqu’aucune autre valeur n’est fournie lors de l’appel de la fonction. La fonction suivante calcule une puissance. Si aucun exposant n’est précisé, celui-ci vaut automatiquement 2.
Accueil
double puissance(double base, int exposant = 2) return std::pow(base, exposant); std::cout << puissance(5) << std::endl; std::cout << puissance(5, 3) << std::endl; Le programme affiche : Comment cela fonctionne-t-il ? Dans la déclaration de la fonction, indique que le paramètre exposant prendra automatiquement la valeur 2 si aucune autre valeur n’est Ainsi, les deux appels suivants sont équivalents : utilise la valeur 3, qui remplace la valeur par défaut. Plusieurs paramètres Une fonction peut posséder plusieurs paramètres, dont certains seulement ont une valeur par défaut. void rectangle(int largeur, Les appels suivants sont alors possibles :
Accueil
Dans le premier cas, la hauteur vaut automatiquement 5. Dans le second, elle vaut 10. Une règle importante Les paramètres possédant une valeur par défaut doivent toujours être placés à la fin de la liste des Par exemple, cette déclaration est correcte : void rectangle(int largeur, En revanche, celle-ci est interdite : void rectangle(int largeur = 20, Le compilateur ne pourrait plus déterminer correctement la valeur associée à chaque paramètre lors • Les paramètres par défaut permettent de simplifier les appels de fonction lorsqu’une même valeur est utilisée très souvent. Ils évitent de répéter inutilement certaines valeurs tout en laissant la possibilité de les modifier lorsque cela est nécessaire. Les paramètres par défaut offrent une certaine souplesse, mais ils ne permettent pas de répondre à toutes les situations. Il arrive que l’on souhaite définir plusieurs fonctions portant le même nom mais réalisant des traitements différents. C’est le principe de la surcharge des fonctions, que nous allons découvrir dans la section suivante. La surcharge des fonctions Pourquoi surcharger une fonction ? Il arrive que plusieurs fonctions réalisent la même tâche, mais avec des types de données différents. Par exemple, nous pourrions souhaiter calculer le carré d’un entier ou celui d’un nombre réel. Les deux fonctions effectuent exactement la même opération. Il serait dommage de devoir leur donner des noms différents. La surcharge des fonctions permet d’utiliser le même nom pour plusieurs fonctions, à condition que leur liste de paramètres soit différente. Considérons les deux fonctions suivantes.
Accueil
double carre(double x) Elles portent le même nom mais leurs paramètres sont différents. Nous pouvons alors écrire : std::cout << carre(5) << std::endl; std::cout << carre(2.5) << std::endl; Le programme affiche : Comment le compilateur choisit-il ? Lorsque le compilateur rencontre l’appel il choisit automatiquement la fonction car l’argument est un entier. En revanche, pour l’appel il sélectionne automatiquement double carre(double x) car l’argument est un nombre réel. Le programmeur utilise donc toujours le même nom de fonction. C’est le compilateur qui choisit automatiquement la version correspondant le mieux aux arguments fournis.
Accueil
Les paramètres doivent être différents Deux fonctions peuvent être surchargées si leur liste de paramètres est différente. Par exemple, les deux fonctions suivantes sont parfaitement valides. int maximum(int a, int b); double maximum(double a, double b); En revanche, les deux déclarations suivantes sont interdites. int maximum(int a, int b); double maximum(int a, int b); Le type de retour ne suffit pas à distinguer deux fonctions. Le compilateur ne tient compte que du nombre et du type des paramètres. Surcharge et paramètres par défaut Dans certains cas, un paramètre par défaut permet d’éviter la création d’une deuxième fonction. Par exemple, au lieu d’écrire void dessiner(int largeur); void dessiner(int largeur, char caractere); il est possible d’écrire une seule fonction. void dessiner(int largeur, char caractere = ’∗’); Les paramètres par défaut et la surcharge des fonctions sont donc deux mécanismes différents qui permettent de rendre une fonction plus souple. Selon la situation, l’un ou l’autre sera plus approprié. • La surcharge permet de donner le même nom à plusieurs fonctions. • Pour que cela soit possible, les fonctions doivent posséder une liste de paramètres différente. • Le type de retour ne suffit pas à distinguer deux fonctions. Autre exemple de surcharge La fonction valeur absolue. int valeurAbsolue(int x)
Accueil
double valeurAbsolue(double x) Vous vous demandez peut-être comment le compilateur sait quelle fonction appeler. La réponse tient à une caractéristique fondamentale du C++ : c’est un langage fortement typé. Le C++ est un langage fortement typé. Cela signifie que le compilateur connaît le type de chaque variable, de chaque paramètre et de chaque valeur de retour. C’est grâce à cette information qu’il peut distinguer plusieurs fonctions portant le même nom. Lorsqu’une fonction est appelée, le compilateur choisit automatiquement celle dont les paramètres correspondent le mieux aux types des arguments fournis. Tous les langages modernes sont, d’une certaine manière, typés. En revanche, le C++ est généralement présenté comme un langage fortement typé : les types sont au cœur du langage, le compilateur les vérifie rigoureusement et ils influencent directement la résolution des appels de fonctions, la surcharge, les conversions, les templates, etc.
Accueil
cpp-Chap5- Les fonctions
Accueil

Chapitre 6 - La récursivité

Chapitre 6 – La récursivité

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
6.1 Une autre manière de répéter . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2 Comprendre la récursivité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.3 Une première fonction récursive (récursion “descendante” uniquement) . . . . . . . . 6.5.4 Exemple avec avec 5 disques . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.5.5 Écriture du programme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
Une autre manière de répéter Jusqu’à présent, nous avons appris à répéter des instructions grâce aux différentes boucles du C++, comme for, while ou do…while. Ces structures permettent d’exécuter plusieurs fois les mêmes instructions en faisant évoluer progressivement une ou plusieurs variables. Il existe cependant une autre manière de résoudre certains problèmes. Au lieu d’utiliser une boucle, une fonction peut tout simplement s’appeler elle-même. Cette technique porte le nom de récursivité. La récursivité est une approche très élégante qui permet de résoudre naturellement certains problèmes dont la structure se répète. On la rencontre dans de nombreux domaines de l’informatique : parcours d’arbres, exploration de labyrinthes, recherche dans des dossiers, algorithmes de tri, intelligence artificielle et bien d’autres encore. Avant d’étudier cette nouvelle technique, il est important de bien comprendre qu’il existe deux grandes façons de répéter une opération. Utilise une boucle (for, while, La répétition est contrôlée par une variable de boucle. Très adaptée aux traitements répétitifs simples. Une fonction s’appelle elle-même. La répétition est obtenue par des appels successifs de la fonction. Très adaptée aux problèmes qui se décomposent naturellement en sousproblèmes plus petits. Aucune de ces deux approches n’est systématiquement meilleure que l’autre. Certaines situations se prêtent naturellement à une solution itérative, tandis que d’autres sont beaucoup plus simples à exprimer de manière récursive. Un bon programmeur doit connaître les deux techniques afin de choisir la plus adaptée au problème qu’il souhaite résoudre. • Une itération répète des instructions grâce à une boucle. • Une récursivité répète un traitement en faisant s’appeler une fonction elle-même. • Les deux approches permettent souvent de résoudre un même problème. Comprendre la récursivité Avant d’écrire notre première fonction récursive, essayons de comprendre le principe de la récursivité à l’aide de quelques situations de la vie courante. Chaque appel récursif rapproche progressivement le programme du cas de base, c’est-à-dire du moment où la fonction peut terminer son travail sans effectuer de nouvel appel. Autrement dit, au lieu de résoudre directement un problème parfois complexe, on le simplifie petit à petit jusqu’à obtenir un cas très simple, dont la solution est immédiate. Imaginez une poupée gigogne (ou poupée russe). Lorsque vous l’ouvrez, vous découvrez une seconde poupée. À l’intérieur de celle-ci se trouve une troisième poupée. Puis une quatrième. Et ainsi de suite, jusqu’à la dernière, qui ne contient plus rien. Chaque poupée est construite sur le même principe que la précédente, mais elle est simplement plus proche du but qui est : ne rien La récursivité fonctionne exactement de la même manière : chaque appel de fonction traite une version du même problème, qui à chaque itération s’approche d’une solution élémentaire. La récursivité consiste toujours à rapprocher progressivement un problème d’un cas très simple dont la solution est immédiate. Ce cas particulier, appelé cas de base ou condition d’arrêt, est indispensable. Sans lui, les appels récursifs continueraient indéfiniment. Une première fonction récursive (récursion “descendante” uniquement) Imaginons que vous deviez annoncer un compte à rebours avant le décollage d’une fusée. Vous commencez par annoncer : Le travail n’est pas terminé. Il reste exactement le même travail à effectuer, mais en partant de 4. Après avoir annoncé 4, il reste exactement le même travail, mais en partant de 3. Enfin, lorsque le compte atteint 0, il n’y a plus rien à annoncer : le compte à rebours est terminé. Chaque étape rapproche donc naturellement du cas où le travail est terminé. • Une bonne fonction récursive ne cherche jamais à résoudre tout le problème. Elle résout seulement une petite partie, puis laisse la même fonction résoudre le reste. Fonction « Compte à rebours »
Accueil
void compteARebours(int n) std::cout << n << ’\n’; // Travail avant compteARebours(n – 1); // Appel recursif Voilà une variation de la fonction « compte à rebours ». Pouvez-vous prédire le résultat ? void compteARebours(int n) std::cout << n << ’\n’; compteARebours(n – 1); std::cout << « Je remonte » << std::endl; Prenez le temps de réfléchir avant de lire la suite du chapitre. Jusqu’à présent, nous avons observé qu’une fonction récursive commence par descendre jusqu’au cas de base, puis remonte progressivement. Mais une question se pose naturellement : Comment le programme sait-il où reprendre son exécution après chaque appel récursif ? Pour répondre à cette question, imaginons un escalier numéroté de 10 à 1. Nous nous trouvons au sommet, sur la marche 10. Notre objectif est de calculer la somme des numéros de toutes les marches. Au lieu d’additionner immédiatement les valeurs, nous décidons de descendre jusqu’à la première marche. Ce n’est qu’une fois arrivé en bas que nous commencerons à remonter en effectuant les Le programme suivant réalise exactement cette opération. int sommeNEntiers(int n) std::cout << « Je descends sur la marche  »
Accueil
int resultat = sommePartielle + n; int sommePartielle = sommeNEntiers(n – 1); std::cout << « Je remonte a la marche  » std::cout << « \nSomme =  » << sommeNEntiers(10) Avant de comprendre le calcul lui-même, observons ce qui se passe pendant la descente. À chaque appel de la fonction, le programme doit mémoriser l’endroit où il devra reprendre son exécution une fois l’appel récursif terminé. Pour cela, il place les informations nécessaires au sommet d’une structure appelée pile d’appels (en anglais call stack). Au début de l’exécution, la pile contient uniquement la fonction main(). Après l’appel de sommeNEntiers(10) : —————–sommeNEntiers(10) —————–sommeNEntiers(9) —————–sommeNEntiers(10)
Accueil
—————–sommeNEntiers(8) —————–sommeNEntiers(9) —————–sommeNEntiers(10) La pile continue ainsi à grandir jusqu’à atteindre la marche 1. À cet instant, la fonction sommeNEntiers(1) atteint le cas de base et renvoie simplement la valeur 1. La pile commence alors à se vider. La fonction sommeNEntiers(2) reprend son exécution exactement après l’appel récursif. Elle reçoit la valeur 1, calcule : et renvoie la valeur 3. La fonction sommeNEntiers(3) reprend ensuite son exécution. Elle reçoit la valeur 3, calcule : et renvoie la valeur 6. et ainsi de suite jusqu’à la marche 10. Finalement, la dernière fonction renvoie : Cette valeur est ensuite reçue par la fonction main(), qui l’affiche à l’écran. La sortie exact est : Somme = Je descends sur la marche 10 Je descends sur la marche 9 Je descends sur la marche 8 Je descends sur la marche 7 Je descends sur la marche 6 Je descends sur la marche 5 Je descends sur la marche 4 Je descends sur la marche 3 Je descends sur la marche 2 Je descends sur la marche 1 Je remonte a la marche 2 : 1 + 2 = 3 Je remonte a la marche 3 : 3 + 3 = 6 Je remonte a la marche 4 : 6 + 4 = 10
Accueil
Je remonte a la marche 5 : 10 + 5 = 15 Je remonte a la marche 6 : 15 + 6 = 21 Je remonte a la marche 7 : 21 + 7 = 28 Je remonte a la marche 8 : 28 + 8 = 36 Je remonte a la marche 9 : 36 + 9 = 45 Je remonte a la marche 10 : 45 + 10 = 55 Program ended with exit code: 0 • Une fonction récursive ne perd jamais sa place dans le programme. • Lorsqu’elle appelle une nouvelle fois la même fonction, son exécution est simplement • Toutes les informations nécessaires pour reprendre l’exécution sont conservées dans la • Les fonctions reprennent ensuite leur exécution dans l’ordre inverse de leur appel. La factorielle est souvent utilisée pour illustrer la récursivité. Vous êtes maintenant capable de l’écrire vous-même en quelques lignes. Maintenant que nous avons compris le rôle de la pile d’appels et le déroulement de la récursivité, nous pouvons simplifier la fonction. Les variables intermédiaires, qui étaient utiles pour suivre le raisonnement, ne sont plus nécessaires. int sommeNEntiers(int n) return n + sommeNEntiers(n – 1); std::cout << sommeNEntiers(10) << ’\n’;
Accueil
On pourrait tout aussi bien écrire if (n == 1) return 1; Cependant pour des raisons pédagogiques (chaque instruction mérite sa ligne) on utilisera la Les Tours de Hanoï constituent l’un des exemples les plus célèbres de récursivité. Le jeu est composé de trois tours, notées A, B et C, ainsi que d’une pile de disques de diamètres Au départ, tous les disques sont empilés sur une même tour, du plus grand au plus petit. Le but est de déplacer toute la pile sur une autre tour en respectant les deux règles suivantes : • un seul disque peut être déplacé à la fois ; • un disque plus grand ne peut jamais être posé sur un disque plus petit. Avant d’écrire le moindre programme, essayons de résoudre le problème nous-mêmes. Commencez par dessiner trois tours et trois disques numérotés de 1 à 3, le disque 1 étant le plus Chaque déplacement sera noté sous la forme • déplacer le disque 1 ; • de la tour A vers la tour B. Essayez maintenant de résoudre le problème avant de poursuivre la lecture.
Accueil
– un seul disque est déplacé ; – aucun disque plus grand n’est jamais posé sur un disque plus petit. La pile finale est bien sur B : 3 − 2 − 1. Recommencez le même exercice avec quatre disques. Cette fois, ne cherchez pas à résoudre complètement le problème. Arrêtez-vous dès que les trois plus petits disques sont empilés sur une autre tour. Que remarquez-vous ? Fig. 6.1 – Schéma. Les trois plus petits disques se retrouvent exactement dans la même configuration que le problème précédent. Autrement dit, le problème à quatre disques contient le problème à trois disques. • Le problème des Tours de Hanoï possède une propriété remarquable : Pour déplacer une pile de n disques, il faut d’abord savoir déplacer une pile de n − 1 disques. • Nous venons de découvrir le principe fondamental de la récursivité. Nous nous arrêtons ici. Inutile de poursuivre les déplacements. Observez simplement la configuration Les trois plus petits disques sont maintenant empilés exactement comme au début de l’exercice précédent. Nous avons donc retrouvé le problème à trois disques. Les essais réalisés avec trois puis quatre disques nous ont permis de faire une observation importante : le problème semble toujours se reproduire avec un disque de moins. Supposons maintenant que nous disposions d’une pile de n disques. Comment déplacer cette pile complète sur une autre tour ? Le plus grand disque est situé tout en bas de la pile. Tant qu’il est recouvert par les n − 1 autres disques, il est impossible de le déplacer. Il faut donc commencer par déplacer les n − 1 plus petits disques sur la troisième tour. Nous pouvons alors déplacer le plus grand disque vers sa tour de destination. Il ne reste plus qu’à replacer les n − 1 petits disques au-dessus de lui.
Accueil
Nous obtenons ainsi une méthode générale. • Pour déplacer une pile de n disques : • déplacer les n − 1 plus petits disques vers la tour auxiliaire ; • déplacer le plus grand disque vers la tour de destination ; • déplacer les n − 1 plus petits disques sur le plus grand disque. Remarquez que les première et troisième étapes consistent exactement à résoudre le même problème, mais avec une pile contenant un disque de moins. Nous retrouvons donc naturellement le principe de la récursivité. Exemple avec avec 5 disques Fig. 6.2 – Schéma pour n = 5. Les rôles des tours A, B et C sont interchangeables. Dans notre raisonnement, A est toujours la tour de départ, C la tour d’arrivee et B la tour auxiliaire. Mais rien n’empêche de choisir une autre tour comme destination. Écriture du programme Nous savons maintenant comment résoudre le problème des Tours de Hanoï. Il ne reste plus qu’à traduire notre raisonnement en langage C++. Pour déplacer une pile de disques, notre fonction devra connaître quatre informations : • le nombre de disques à déplacer ; • la tour de départ ; • la tour de destination ; • la tour auxiliaire. Nous pouvons donc commencer par écrire la déclaration de la fonction.
Accueil
Le paramètre n représente le nombre de disques à déplacer. Les trois paramètres de type char contiennent simplement le nom des trois tours. Ils pourront prendre les valeurs ’A’, ’B’ et ’C’. Avant d’écrire le reste de la fonction, une question importante se pose. Quand la récursivité doit-elle s’arrêter ? Comme toute fonction récursive, notre fonction doit posséder un cas de base. Celui-ci est particulièrement simple. S’il ne reste qu’un seul disque à déplacer, aucune réflexion n’est nécessaire : il suffit de le déplacer directement vers sa tour de destination. Le début de notre fonction devient alors : L’instruction return met immédiatement fin à la fonction. Si nous arrivons dans ce cas, il est inutile de poursuivre l’exécution. Nous pouvons maintenant nous occuper du cas général. Reprenons le raisonnement découvert dans le paragraphe précédent. Pour déplacer une pile de n disques, il faut : 1. déplacer les n − 1 plus petits disques vers la tour auxiliaire ; 2. déplacer le plus grand disque vers la tour de destination ; 3. déplacer les n − 1 plus petits disques sur le plus grand disque. Il suffit maintenant de traduire chacune de ces trois étapes en C++. La première étape consiste à déplacer les n − 1 plus petits disques. Cette instruction ressemble beaucoup à la déclaration de la fonction. La seule différence est que le nombre de disques a diminué d’une unité et que les rôles des tours ont changé. Nous pouvons ensuite déplacer le plus grand disque.
Accueil
Enfin, il reste à déplacer les n − 1 plus petits disques sur le plus grand. En réunissant toutes ces parties, nous obtenons la fonction complète. Cette fonction ne fait finalement rien d’autre que reproduire fidèlement le raisonnement que nous avons construit progressivement. Elle ne contient aucun « tour de magie ». Chaque appel récursif correspond simplement à l’une des étapes de notre méthode de résolution. Le programme complet s’écrit donc ainsi : Code 6.1 – Programme Les tours de Hanoï
Accueil
std::cout << depart <<  » ->  » << arrivee << ’\n’; // Deplacer les n-1 plus petits disques hanoi(n – 1, depart, auxiliaire, arrivee); // Deplacer le plus grand disque std::cout << depart <<  » ->  » << arrivee << ’\n’; int main(int argc, const char * argv[]) hanoi(20, ’A’, ’C’, ’B’); // Deplacer les n-1 plus petits disques hanoi(n – 1, auxiliaire, arrivee, depart); Avec 20 disques le nombre de déplacements est de 2n − 1 = 1048575.
Accueil
Σ Complément mathématique Le nombre de déplacements effectués par notre fonction récursive vérifie la relation suivante : T (n) = 2T (n − 1) + 1, où T (n) désigne le nombre de déplacements nécessaires pour une pile de n disques. Cette relation traduit exactement notre algorithme : 1. déplacer les n − 1 plus petits disques ; 2. déplacer le plus grand disque (un seul déplacement) ; 3. déplacer à nouveau les n − 1 plus petits disques. Calculons les premières valeurs : T (2) = 2 × 1 + 1 = 3, T (3) = 2 × 3 + 1 = 7, T (4) = 2 × 7 + 1 = 15, T (5) = 2 × 15 + 1 = 31. On reconnaît immédiatement la suite 1, 3, 7, 15, 31, . . . qui s’écrit plus simplement Les lecteurs connaissant le triangle de Pascal remarqueront peut-être que la somme des nombres d’une ligne vaut toujours une puissance de deux : Les Tours de Hanoï conduisent elles aussi aux puissances de deux, car chaque problème se décompose en deux sous-problèmes identiques. Le terme −1 provient simplement du fait qu’il faut ajouter un unique déplacement entre les deux appels récursifs. On peut d’ailleurs écrire : T (n) + 1 = 2 T (n − 1) + 1 , ce qui conduit immédiatement à
Accueil
La légende raconte que, dans un temple de Bénarès, des moines déplacent sans interruption une tour de 64 disques selon les règles des Tours de Hanoï. Lorsque le dernier déplacement sera effectué… le 264 − 1 = 18 446 744 073 709 551 615 Soit environ 18, 4 milliards de milliards de déplacements ! Même en déplaçant un disque par seconde, il faudrait environ 585 milliards d’années. Pour donner un ordre de grandeur, l’âge actuel de l’Univers est estimé à environ 13, 8 milliards La récursivité permet à une fonction de résoudre un problème en le ramenant à un problème plus petit de même nature. Toute fonction récursive doit posséder un cas de base, sans lequel les appels se poursuivraient indéfiniment. Les exemples étudiés dans ce chapitre ont également montré que la récursivité peut produire un code très élégant, mais qu’elle utilise la pile d’appels et peut parfois conduire à un très grand nombre Une fonction récursive : • s’appelle elle-même ; • doit toujours posséder un cas de base ; • résout généralement un problème en le ramenant à un problème plus petit ; • utilise la pile d’appels pour conserver les appels encore inachevés.
Accueil
cpp-Chap6- La récursivité
Accueil

Chapitre 7 - Les espaces de noms

Chapitre 7 : Les espaces de noms

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
7 Les espaces de noms (namespace) 7.1 Pourquoi les espaces de noms ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.4 Créer son propre espace de noms . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5 L’instruction using namespace . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Les espaces de noms (namespace)
Accueil
Les espaces de noms (namespace) Pourquoi les espaces de noms ? Au fur et à mesure que les programmes grandissent, ils contiennent de plus en plus de fonctions, de variables Imaginons maintenant que deux bibliothèques différentes définissent chacune une fonction portant le même La première bibliothèque utilise cette fonction pour afficher des résultats mathématiques, tandis que la seconde l’utilise pour afficher des résultats physiques. Que se passerait-il si notre programme utilisait les deux bibliothèques ? Comment le compilateur pourrait-il savoir laquelle de ces deux fonctions nous souhaitons appeler ? Il ne le pourrait tout simplement pas. Il est donc nécessaire de disposer d’un mécanisme permettant de regrouper les éléments appartenant à une même bibliothèque afin d’éviter les conflits de noms. C’est précisément le rôle des espaces de noms (namespace). • Un espace de noms permet de regrouper des fonctions, des variables ou des classes ayant un lien logique et d’éviter les conflits entre des éléments portant le même nom. Créons deux espaces de noms contenant chacun une fonction portant le même nom. std::cout << « Affichage mathematique\n »; 1. On verra plus loin les classes très en détail. Les classes sont la base de la propgrammation orientée objets aussi appelée Les espaces de noms (namespace) std::cout << « Affichage physique\n »; Physique::afficher(); L’exécution de ce programme produit le résultat suivant : Affichage mathematique Les deux fonctions portent exactement le même nom : afficher(). Sans les espaces de noms, le compilateur ne pourrait pas savoir laquelle appeler. Grâce aux espaces de noms, chaque fonction est identifiée de manière unique. L’opérateur ::, appelé opérateur de résolution de portée, permet d’indiquer dans quel espace de noms se trouve la fonction que l’on souhaite utiliser. Ainsi, appelle la fonction afficher() de l’espace de noms Math, tandis que Physique::afficher(); appelle celle de l’espace de noms Physique. Le symbole : : est appelé opérateur de résolution de portée (scope resolution operator en anglais). • Deux espaces de noms différents peuvent contenir des fonctions, des variables ou des classes portant le même nom. • L’opérateur :: permet de préciser à quel espace de noms appartient l’élément que l’on souhaite
Accueil
Les espaces de noms (namespace) Il ne faut pas confondre une bibliothèque et un espace de noms. Une bibliothèque est un ensemble de fonctions, de classes et d’autres éléments réutilisables. Un espace de noms est simplement un mécanisme qui permet d’organiser ces éléments et d’éviter les conflits de noms. Une même bibliothèque peut contenir plusieurs espaces de noms, et un programme peut créer ses propres espaces de noms sans pour autant constituer une bibliothèque. Pourquoi écrit-on std::cout ? Depuis le début de ce livre, nous écrivons des instructions telles que : std::cout << « Bonjour ! » << std::endl; Sans vraiment nous demander ce que signifie std. Nous pouvons maintenant répondre à cette question. std est le nom d’un espace de noms. Il regroupe les principaux éléments de la bibliothèque standard du C++, parmi lesquels cout, cin, endl et bien d’autres. std::cout << « Bonjour\n »; signifie simplement : Utiliser l’objet cout appartenant à l’espace de noms std. De la même manière, désigne la fonction afficher() appartenant à l’espace de noms Math. Il ne faut pas confondre une bibliothèque et un espace de noms. Une bibliothèque est un ensemble de fonctions, de classes, de variables et d’autres éléments réutilisables. Un espace de noms est un mécanisme qui permet d’organiser ces éléments et d’éviter les conflits de Une bonne image consiste à comparer une bibliothèque à un classeur et les espaces de noms à des intercalaires. Le classeur contient les documents. Les intercalaires ne contiennent rien par eux-mêmes : ils servent simplement à classer les documents afin de les retrouver facilement. La bibliothèque standard du C++ est un vaste classeur. L’un de ses principaux intercalaires s’appelle std. C’est la raison pour laquelle nous écrivons std::cout, std::endl, etc. Créer son propre espace de noms Les espaces de noms ne sont pas réservés à la bibliothèque standard du C++. Vous pouvez également créer les vôtres afin de regrouper des fonctions, des variables ou des classes ayant un lien logique.L’exemple suivant rassemble deux fonctions relatives à la géométrie dans un même espace de noms.
Accueil
double aireCarre(double cote) Les espaces de noms (namespace) std::cout << « Aire :  » << Geometrie::aireCarre(5.0) double perimetreCarre(double cote) std::cout << « Perimetre :  » << Geometrie::perimetreCarre(5.0) Les deux fonctions appartiennent maintenant à l’espace de noms Geometrie. Pour les utiliser, il suffit de faire précéder leur nom de Geometrie::. Même dans un projet qui ne constitue pas une bibliothèque, cette manière de regrouper les fonctions améliore la lisibilité du programme et facilite son organisation. • Les espaces de noms permettent non seulement d’éviter les conflits de noms, mais aussi de regrouper les éléments ayant un même domaine d’application. • Ils constituent un excellent moyen d’organiser un programme de grande taille. L’instruction using namespace Comme nous l’avons vu, les éléments de la bibliothèque standard appartiennent à l’espace de noms std. C’est pourquoi nous écrivons : std::cout << « Bonjour\n »; Il est toutefois possible d’éviter de répéter std:: grâce à l’instruction suivante : using namespace std; Après cette instruction, il devient possible d’écrire simplement : cout << « Bonjour\n »;
Accueil
Les espaces de noms (namespace) • L’instruction using namespace std; est pratique dans les petits programmes et dans les exemples pédagogiques. • En revanche, dans les projets de grande taille, elle peut provoquer des conflits de noms avec d’autres bibliothèques. • C’est pourquoi de nombreux développeurs préfèrent conserver l’écriture std::cout, std::cin, Les espaces de noms permettent d’organiser un programme et d’éviter les conflits entre des éléments portant le même nom. Nous avons également découvert que les objets cout et cin appartiennent à l’espace de noms std, qui est le principal espace de noms de la bibliothèque standard du C++. Dans la suite de cet ouvrage, nous continuerons à utiliser cette notation afin de conserver des programmes clairs et faciles à comprendre.
Accueil
cpp-Chap7- Les espaces de noms (namespace)
Accueil
Termes à ajouter au glossaire

Chapitre 8 - Les tableaux

Chapitre 8 – Les tableaux

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
8.3 Lire et modifier les éléments d’un tableau . . . . . . . . . . . . . . . . . . . . . . . . . 8.5.1 Calcul de la somme des éléments . . . . . . . . . . . . . . . . . . . . . . . . . . 8.5.3 Recherche de la plus grande valeur . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
Pourquoi un tableau ? Jusqu’à présent, toutes les variables que nous avons utilisées ne pouvaient contenir qu’une seule valeur. Par exemple, si nous voulons mémoriser l’âge d’une personne, une simple variable suffit : Mais imaginons maintenant que nous souhaitions enregistrer les notes de 30 étudiants. Une première idée serait de créer une variable pour chaque note : Cette solution présente plusieurs inconvénients : • elle est longue à écrire ; • elle devient difficile à modifier ; • il est pratiquement impossible de traiter toutes les notes avec une boucle. Nous avons besoin d’un moyen de regrouper plusieurs valeurs de même type sous un seul nom. C’est précisément le rôle d’un tableau. Dans les sections suivantes, nous allons découvrir comment déclarer un tableau, accéder à ses éléments et utiliser des boucles pour les parcourir efficacement. Déclarer un tableau La déclaration d’un tableau ressemble beaucoup à celle d’une variable classique. La différence est que l’on indique également le nombre d’éléments que le tableau pourra contenir. Par exemple, la déclaration suivante crée un tableau capable de mémoriser cinq entiers : Le nom du tableau est notes. Comme pour une variable classique, ce nom désigne une seule variable. La valeur placée entre crochets indique le nombre d’éléments que le tableau peut contenir. Ici, le tableau pourra mémoriser cinq entiers. Fig. 8.1 – Le tableau notes est une seule variable contenant cinq éléments de type int. Les éléments sont numérotés de 0 à Un tableau est une variable qui contient plusieurs éléments du même type. Chaque élément d’un tableau est repéré par un numéro appelé indice. Dans le tableau suivant, les cinq éléments sont numérotés de 0 à 4. La première question que l’on peut se poser est la suivante : Pourquoi le premier élément porte-t-il l’indice 0 et non 1 ? La réponse est simple : c’est une convention choisie par les concepteurs du langage C++. D’autres langages utilisent également cette convention. Même si ce choix peut sembler surprenant au début, il devient très naturel avec un peu de pratique. Pour accéder à un élément du tableau, on écrit le nom du tableau suivi de son indice entre crochets. Pour afficher la première note, on écrit simplement : std::cout << notes[0]; La taille d’un tableau et ses indices sont deux notions différentes. Un tableau de cinq éléments possède les indices : 0 1 2 3 4. Le plus grand indice vaut donc toujours taille − 1 et le premier élément d’un tableau possède toujours l’indice 0.
Accueil
Que va donner le code suivant ? int main(int argc, const char * argv[]) Lire et modifier les éléments d’un tableau Une fois le tableau déclaré, chaque élément peut être utilisé comme une variable classique. Pour modifier la valeur d’un élément, il suffit d’utiliser son indice. Les cinq éléments du tableau contiennent maintenant les valeurs indiquées. Pour lire le contenu d’un élément, on utilise exactement la même notation. cout << notes[0] << endl; cout << notes[3] << endl; dont le résultat est : Un élément peut être modifié ainsi : cout << notes[2] << endl; ce qui donne bien entendu 14.
Accueil
cout << « Premiere note :  » << notes[0] << endl; cout << « Derniere note :  » << notes[4] << endl; Le resultat est naturellement Parcourir un tableau Jusqu’à présent, nous avons accédé aux éléments du tableau un par un. Par exemple, pour afficher les cinq notes d’un tableau, nous pourrions écrire : cout << notes[0] << endl; cout << notes[1] << endl; cout << notes[2] << endl; cout << notes[3] << endl; cout << notes[4] << endl; Cette méthode fonctionne parfaitement. En revanche, elle devient rapidement impraticable lorsque le tableau contient un grand nombre d’éléments. Imaginez un tableau contenant 500, 10 000 ou même un million de valeurs ! Heureusement, une boucle permet de parcourir automatiquement tous les éléments du tableau. for (int i = 0; i < 5; i++) cout << notes[i] << endl; La variable i joue ici le rôle d’indice. À chaque tour de boucle, sa valeur augmente d’une unité, ce qui permet d’accéder successivement aux différents éléments du tableau. La boucle for ne connaît pas le tableau. Elle se contente d’incrémenter la variable i. C’est l’expression notes[i] qui permet d’accéder successivement aux différents éléments du tableau.
Accueil
Que va-t-il se passer ? Le programme suivant affiche-t-il les cinq notes du tableau ? for (int i = 1; i < 5; i++) cout << notes[i] << endl; Si ce n’est pas le cas, quelle note ne sera jamais affichée ? Jusqu’à présent, nous avons appris à déclarer un tableau, à accéder à ses éléments et à les parcourir à l’aide d’une boucle. Les tableaux permettent de résoudre de nombreux problèmes très courants. Dans cette section, nous allons découvrir quelques applications classiques qui illustrent leur utilisation. À la fin de cette section, vous serez capable de : • calculer la somme des éléments d’un tableau ; • calculer une moyenne ; • rechercher la plus grande ou la plus petite valeur ; • compter les éléments répondant à une condition ; • rechercher la présence d’une valeur. Calcul de la somme des éléments Le calcul de la somme des éléments d’un tableau est une opération très fréquente. L’idée consiste à parcourir le tableau et à ajouter chaque élément à une variable appelée accumulateur. int notes[5] = {15, 12, 18, 14, 17}; for (int i = 0; i < 5; i++) somme += notes[i]; // somme = somme + notes[i] cout << « Somme =  » << somme << endl; La variable somme est initialisée à zéro, puis chaque élément du tableau lui est ajouté successivement. Le résultat affiché est :
Accueil
La variable somme est appelée un accumulateur. Son rôle est de mémoriser progressivement le résultat obtenu au cours de la boucle. Calcul de la moyenne Après avoir calculé la somme des éléments d’un tableau, il est très simple d’en déduire la moyenne. La méthode consiste à additionner tous les éléments du tableau, puis à diviser cette somme par le nombre d’éléments. int notes[5] = {15, 12, 18, 14, 17}; for (int i = 0; i < 5; i++) double moyenne = static_cast<double>(somme) / 5; cout << « Moyenne =  » << moyenne << endl; Le programme affiche : La fonction static_cast<double> convertit la variable somme en nombre réel avant la division. Sans cette conversion, la division serait effectuée entre deux entiers et la partie décimale serait perdue. Il est généralement préférable de calculer la somme dans une variable de type int, puis de convertir le résultat en double uniquement au moment de calculer la moyenne. Cette manière de procéder est à la fois simple et efficace. On peut aussi remplacer le nombres de notes par une constante, c’est une excellente habitude. const int NB_NOTES = 5; int notes[NB_NOTES] = {15, 12, 18, 14, 17}; for (int i = 0; i < NB_NOTES; i++) double moyenne = static_cast<double>(somme) / NB_NOTES;
Accueil
Recherche de la plus grande valeur Il est souvent nécessaire de déterminer la plus grande valeur contenue dans un tableau. Pour cela, on suppose dans un premier temps que le premier élément est le plus grand, puis on compare successivement tous les autres éléments à cette valeur. Si un élément plus grand est trouvé, il devient la nouvelle valeur maximale. const int NB_NOTES = 5; for (int i = 1; i < NB_NOTES; i++) if (notes[i] > maximum) int notes[NB_NOTES] = {15, 12, 18, 14, 17}; int maximum = notes[0]; cout << « Maximum =  » << maximum << endl; Le programme affiche : Pourquoi la boucle commence-t-elle avec l’indice 1 et non avec 0 ? En effet, la variable maximum a déjà été initialisée avec la valeur notes[0]. Il est donc inutile de comparer une seconde fois le premier élément avec lui-même. Les tableaux constituent l’un des outils fondamentaux de la programmation. Ils seront utilisés dans de nombreux chapitres de ce livre et serviront de base à des structures de données plus évoluées. Que se passerait-il si l’on écrivait : int maximum = notes[0]; Le programme fonctionnerait-il toujours correctement ?
Accueil
• Un tableau permet de regrouper plusieurs valeurs de même type sous un même nom. • Les éléments d’un tableau sont repérés par un indice qui commence toujours à 0. • Chaque élément d’un tableau peut être lu ou modifié comme une variable ordinaire. • Une boucle for permet de parcourir facilement tous les éléments d’un tableau. • Les tableaux sont particulièrement adaptés pour effectuer des traitements répétitifs tels que le calcul d’une somme, d’une moyenne ou la recherche d’une valeur maximale.
Accueil
cpp-Chap8- Les tableaux
Accueil
Termes à ajouter au glossaire

Chapitre 9 - Les chaînes de caractères

Chapitre 9 – Les chaînes de caractères

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
9 Les chaînes de caractères 9.2 Pourquoi les chaînes de caractères ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3 Une première chaîne de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.5.1 Lien avec les systèmes embarqués . . . . . . . . . . . . . . . . . . . . . . . . . 9.6 Parcourir une chaîne de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.6.1 Parcours d’une chaîne avec un pointeur . . . . . . . . . . . . . . . . . . . . . . 9.7 Copier une chaîne de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.8 Comparer deux chaînes de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.9 Concaténer deux chaînes de caractères . . . . . . . . . . . . . . . . . . . . . . . . . . . Les chaînes de caractères
Accueil
Les chaînes de caractères Dans ce chapitre, nous utilisons les caractères classiques (char), suffisants pour comprendre le fonctionnement des chaînes de caractères. Les systèmes modernes utilisent souvent des encodages plus évolués (comme UTF-8) afin de représenter les caractères de toutes les langues. Ces encodages ne modifient pas les principes étudiés ici et seront abordés plus tard si nécessaire. Pourquoi les chaînes de caractères ? Jusqu’à présent, nous avons appris à mémoriser différents types de données. Par exemple, une variable de type int permet de stocker un nombre entier, tandis qu’une variable de type char permet de mémoriser un seul caractère. Imaginons maintenant que nous souhaitions enregistrer le prénom d’un étudiant. Nous pourrions écrire : char prenom = ’M’; Cette variable contient bien une lettre, mais seulement la première du prénom. Comment représenter le mot complet « Michel » ? Une première idée pourrait être d’utiliser plusieurs variables : Cette solution fonctionne, mais elle présente rapidement de nombreux inconvénients. • Chaque lettre doit être mémorisée dans une variable différente. • Le nombre de variables dépend de la longueur du mot. • Il devient difficile d’afficher, de copier ou de comparer un texte. • Cette méthode est pratiquement inutilisable pour représenter une phrase complète. Nous avons besoin d’une structure plus adaptée au stockage d’un texte. Or, nous connaissons déjà un outil capable de regrouper plusieurs valeurs de même type sous un seul Les chaînes de caractères Une chaîne de caractères n’est rien d’autre qu’un tableau de caractères. Nous allons découvrir dans ce chapitre comment ces tableaux sont organisés en mémoire et comment les manipuler efficacement. À votre avis, comment le langage C++ sait-il où se termine le mot « Michel » lorsqu’il est stocké en mémoire ? Nous répondrons à cette question dans la section suivante. Une première chaîne de caractères Comme une chaîne de caractères est un tableau de char, sa déclaration ressemble beaucoup à celle d’un tableau classique. Le programme suivant crée une chaîne contenant le prénom « Michel » : char prenom[] = « Michel »; À première vue, cette écriture paraît très simple. Pourtant, plusieurs questions se posent immédiatement. • Combien de cases mémoire ont été réservées ? • Chaque lettre occupe-t-elle une case ? • Pourquoi les guillemets (« ) sont-ils utilisés alors qu’un caractère s’écrit entre apostrophes (’) ? • Comment l’ordinateur sait-il où se termine le mot ? Pour répondre à ces questions, nous allons examiner ce qui est réellement stocké en mémoire. Combien de cases mémoire pensez-vous que le tableau prenom contient ? char prenom[] = « Michel »; • Impossible de le savoir Ne cherchez pas la réponse dans la documentation. Essayez simplement de réfléchir avant de poursuivre votre lecture. Regardons la mémoire Lorsqu’un programme est exécuté, les variables sont stockées dans la mémoire de l’ordinateur. Considérons la déclaration suivante : char prenom[] = « Michel »; À première vue, on pourrait penser que la variable prenom contient simplement le mot « Michel ». En réalité, ce qui est stocké en mémoire est beaucoup plus précis. En exécutant les instructions suivantes
Accueil
Les chaînes de caractères char prenom[] = « Michel »; std::cout << sizeof(prenom) << std::endl; std::cout << strlen(prenom) << std::endl; • On constate que sizeof(prenom) et strlen(prenom) ne renvoient pas la même valeur. Nous pouvons représenter le contenu de la mémoire de la manière suivante : Les six premières cases ne posent aucun problème. Elles contiennent les six lettres du prénom. Mais une question reste sans réponse : Que contient la dernière case ? Cette dernière case n’est pas vide. Elle contient un caractère très particulier appelé caractère nul, noté : La mémoire contient donc en réalité : Le caractère ’\0’ marque la fin de la chaîne de caractères. Il ne s’agit pas de la valeur ’0’ (le chiffre zéro), mais d’un caractère spécial dont le code ASCII vaut Toutes les fonctions manipulant des chaînes de caractères recherchent ce caractère pour savoir où le Sans lui, l’ordinateur serait incapable de déterminer où s’arrête le mot. Comment fait strlen() pour trouver 6 ? strlen() est une fonction prédéfinie qui fonctionne comme la fonction longueur() du petit programme suivant : int longueur(const char chaine[]) while (chaine[i] != ’\0’) // fonction equivalente a strlen()
Accueil
Les chaînes de caractères int main(int argc, const char * argv[]) { char prenom[] = « Michel »; std::cout << sizeof(prenom) << std::endl; std::cout << longueur(prenom) << std::endl; Le caractère ’\0’ sert de sentinelle. Lorsqu’un programme le rencontre, il sait que le texte est terminé et qu’il ne doit pas continuer sa lecture. • Une chaîne de caractères en C++ n’est pas un nouveau type de données. C’est simplement un tableau de char dont le dernier élément est toujours le caractère spécial ’\0’. • C’est ce caractère qui indique la fin de la chaîne. char prenom[20] = « Michel »; Que valent les grandeurs std::cout << sizeof(prenom) << std::endl; std::cout << longueur(prenom) << std::endl; Les chaînes littérales Depuis le début de ce chapitre, nous utilisons des écritures comme : char prenom[] = « Michel »; char ville[] = « Geneve »; Les mots « Michel », « Genève » et « Suisse » sont appelés des chaînes littérales. Une chaîne littérale est une suite de caractères écrite directement dans le programme entre guillemets (« ). Lors de la compilation, le compilateur crée automatiquement le tableau de caractères correspondant et ajoute le caractère spécial ’\0’ à la fin de la chaîne. Ainsi, les deux déclarations suivantes sont équivalentes : char prenom[] = « Michel »;
Accueil
’M’,’i’,’c’,’h’,’e’,’l’,’\0’ Les chaînes de caractères La première écriture est simplement beaucoup plus pratique. • Les guillemets ( » « ) permettent d’écrire directement une chaîne de caractères. • Le compilateur ajoute automatiquement le caractère ’\0’ à la fin de la chaîne. ’M’,’i’,’c’,’h’,’e’,’l’ définit un tableau de caractères, mais pas une chaîne de caractères. Un tableau de char n’est pas nécessairement une chaîne de caractères. Pour qu’un tableau de caractères soit une véritable chaîne de caractères, son dernier élément doit être le caractère spécial ’\0’. La fonction strlen(prenom) provoque un comportement indéfini du programme dans ce cas. Lien avec les systèmes embarqués Cette convention est utilisée dans de très nombreuses bibliothèques C/C++, notamment celles destinées aux systèmes embarqués comme les ESP32. Parcourir une chaîne de caractères Comme une chaîne de caractères est un tableau de char, nous pouvons accéder à chacun de ses éléments à l’aide de son indice. Le programme suivant affiche les caractères de la chaîne un par un. char prenom[] = « Michel »; for(int i = 0; prenom[i] != ’\0’; i++) std::cout << prenom[i] << std::endl; Contrairement au chapitre précédent, la boucle ne s’arrête plus lorsque l’indice atteint la taille du tableau. Elle s’arrête lorsqu’elle rencontre le caractère spécial ’\0’.
Accueil
Les chaînes de caractères Fig. 9.1 – Schéma du fonctionnement Si l’on déclare la fonction void afficher(const char chaine[]) la fonction ne connaît pas la taille du tableau reçu. Pour savoir où se termine le texte, elle doit parcourir les caractères jusqu’à rencontrer la sentinelle de fin de chaîne ’\0’. Parcours d’une chaîne avec un pointeur Reprenons la déclaration de la chaine de caractères suivante : char prenom[] = « Michel »; Nous allons parcourir cette chaine avec un pointeur. char prenom[] = »Michel »; char* p = &prenom[0]; while(*p != ’\0’) std::cout << std::endl; La sortie de ce programme est
Accueil
Les chaînes de caractères On voit que le pointeur reçoit l’adresse du premier caractère. Ensuite le pointeur est incrémenté jusqu’à ce que la valeur du caractère lu soit le caractère ’\0’. Cette première écriture est parfaitement correcte et correspond directement à ce que nous avons appris sur les pointeurs : char* p = &prenom[0]; Cependant, dans la plupart des expressions, le nom d’un tableau est automatiquement converti en l’adresse de son premier élément. On écrit donc généralement : Les deux écritures sont équivalentes. En remplaçant la déclaration du pointeur par l’écriture habituelle, le reste du programme demeure std::endl ne produit aucun affichage à lui seul. Il doit être envoyé à un flux de sortie, comme std::cout, à l’aide de l’opérateur <<.
Accueil
Les chaînes de caractères Essayez d’expliquer la sortie de ce programme. char prenom[] = »Michel »; char* p = &prenom[0]; while(*p != ’\0’) << static_cast<const void*>(p)
Accueil
Les chaînes de caractères Programme qui retourne VRAI si un mot est un palindrome. bool pallindrome(char pal[]) char* debut = &pal[0]; = &pal[strlen(pal)-1]; char mot[] = »radar »; if (pallindrome(mot)) std::cout << « VRAI\n »; std::cout << « FAUX\n »; Copier une chaîne de caractères Après avoir appris à parcourir une chaîne de caractères, nous pouvons maintenant réaliser une première opération utile : copier son contenu dans une autre chaîne. Le principe est très simple. Nous parcourons la chaîne source caractère par caractère et nous recopions chaque caractère dans une seconde chaîne. Lorsque nous rencontrons le caractère ’\0’, nous savons que la copie est terminée. Il est indispensable de placer ce même caractère à la fin de la chaîne destination afin que la nouvelle chaîne soit elle aussi correctement terminée. void copier(char destination[], char source[]) while(source[i] != ’\0’) destination[i] = source[i]; destination[i] = ’\0’;
Accueil
Les chaînes de caractères char source[] = « Bonjour »; char destination[20] = «  »; copier(destination, source); std::cout << destination << std::endl; La boucle copie un à un tous les caractères de la chaîne source. Lorsque le caractère ’\0’ est rencontré, la boucle s’arrête. Il est alors indispensable de placer ce même caractère à la fin de la chaîne destination, faute de quoi celle-ci ne serait plus une chaîne de caractères valide. • Une fonction de copie ne doit pas seulement recopier les caractères visibles. Elle doit également ajouter le caractère ’\0’ qui marque la fin de la chaîne. Comparer deux chaînes de caractères Il est souvent nécessaire de savoir si deux chaînes de caractères contiennent exactement le même Pour cela, nous comparons leurs caractères un à un. Dès que deux caractères sont différents, nous pouvons conclure que les deux chaînes ne sont pas identiques. Si, au contraire, nous atteignons simultanément le caractère ’\0’ des deux chaînes, alors elles sont identiques. bool comparer(char chaine1[], char chaine2[]) while(chaine1[i] != ’\0’ && chaine2[i] != ’\0’) if(chaine1[i] != chaine2[i]) char mot1[] = « Michel »; char mot2[] = « Michel »; return chaine1[i] == chaine2[i];
Accueil
Les chaînes de caractères if(comparer(mot1, mot2)) std::cout << « Les deux chaines sont identiques. » << std::endl; std::cout << « Les deux chaines sont differentes. » << std::endl; Les deux chaines sont identiques. La boucle compare les deux chaînes caractère par caractère. Si une différence est rencontrée, la fonction retourne immédiatement la valeur false. Lorsque la boucle se termine, deux situations sont possibles : • les deux chaînes sont arrivées simultanément au caractère ’\0’ : elles sont identiques ; • une seule chaîne est arrivée au caractère ’\0’ : l’une est plus courte que l’autre. return chaine1[i] == chaine2[i]; retourne donc true uniquement si les deux chaînes se terminent exactement au même endroit. • Deux chaînes de caractères sont identiques si elles contiennent les mêmes caractères dans le même ordre et si leur premier caractère ’\0’ apparaît au même endroit. • Deux tableaux de caractères peuvent donc être différents tout en contenant la même chaîne de caractères, car tout ce qui se trouve après le premier ’\0’ ne fait plus partie Concaténer deux chaînes de caractères Concaténer deux chaînes consiste à ajouter la seconde à la suite de la première. Pour cela, nous devons tout d’abord rechercher la fin de la première chaîne, c’est-à-dire son premier caractère ’\0’. Nous recopions ensuite les caractères de la seconde chaîne à partir de cet emplacement avant d’ajouter un nouveau caractère ’\0’ à la fin. void concatener(char destination[], char source[]) while(destination[i] != ’\0’) while(source[j] != ’\0’) destination[i] = source[j];
Accueil
Les chaînes de caractères destination[i] = ’\0’; char texte[30] = « Bonjour »; char ajout[] =  » Michel »; concatener(texte, ajout); std::cout << texte << std::endl; La première boucle recherche le premier caractère ’\0’ de la chaîne destination. La seconde boucle recopie ensuite les caractères de la chaîne source à partir de cet emplacement. Comme pour la fonction copier(), il est indispensable d’ajouter un nouveau caractère ’\0’ à la fin de la chaîne obtenue. • Concaténer deux chaînes consiste simplement à copier la seconde chaîne à partir de la fin de la première. • La chaîne destination doit être suffisamment grande pour contenir le résultat de la concaténation. Dans le cas contraire, le programme écrira au-delà des limites du tableau, ce qui conduit à un comportement indéfini. Dans les exemples précédents, nous avons supposé que la chaîne de destination était suffisamment grande. Nous verrons par la suite comment garantir qu’elle dispose toujours de la mémoire nécessaire. Les chaînes de caractères constituent un cas particulier de tableau de char. Leur principale caractéristique est la présence d’un premier caractère ’\0’ qui marque la fin de la chaîne. Dans ce chapitre, nous avons appris à parcourir une chaîne de caractères, à la copier, à la comparer et à la concaténer. Ces opérations, bien que simples, sont à la base de très nombreux programmes. Dans les chapitres suivants, nous verrons comment rendre ces fonctions plus générales et plus sûres. Nous découvrirons notamment comment transmettre efficacement des chaînes à des fonctions et comment lever la limitation imposée par la taille fixe des tableaux de caractères.
Accueil
cpp-Chap9- Les chaînes de caractères
Accueil
Les chaînes de caractères
Accueil
Termes à ajouter au glossaire

Chapitre 10 - Les structures

Chapitre 10 – Les structures

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
10 Créer de nouveaux types de données 10.3 Créer une variable de type Date . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.4 Initialiser une structure . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.5 Copie et affectation d’une structure . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.6 Copie et affectation d’une structure . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.7 Passer une structure à une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.8 Retourner une structure . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Créer de nouveaux types de données
Accueil
Créer de nouveaux types de données Jusqu’à présent, nous avons manipulé des variables indépendantes les unes des autres. Supposons maintenant que nous souhaitions représenter une date. Ces trois variables représentent-elles trois informations différentes ? En réalité, non. Elles décrivent toutes les trois une seule et même information : une date. Il serait donc pratique de pouvoir les regrouper au sein d’une seule variable. Le langage C++ permet de créer de nouveaux types de données grâce aux structures. Déclarer une structure Une structure est un ensemble de variables regroupées sous un même nom. La structure suivante permet de représenter une date. Le mot-clé struct indique que nous définissons une nouvelle structure. Dans cet exemple, la structure Date contient trois membres : À partir de maintenant, Date devient un nouveau type de données, au même titre que int, double Créer une variable de type Date Une fois la structure définie, nous pouvons créer des variables de ce nouveau type exactement comme nous le faisons avec les types fondamentaux. Créer de nouveaux types de données aujourdHui.jour = 31; aujourdHui.mois = 7; aujourdHui.annee = 2026; std::cout << aujourdHui.jour << « / » << aujourdHui.mois << « / » << aujourdHui.annee << std::endl; La variable aujourdHui est une variable de type Date. Elle contient trois membres : jour, mois et annee. Pour accéder à un membre d’une structure, nous utilisons l’opérateur point (.). désigne le membre jour de la structure aujourdHui. désigne le membre mois, tandis que désigne le membre annee. L’opérateur . permet donc d’accéder individuellement à chacun des membres d’une structure. • Une structure est un nouveau type de données. Pour accéder à l’un de ses membres, on utilise l’opérateur point (.). • Par exemple, l’expression désigne le membre jour de la variable aujourdHui.
Accueil
Créer de nouveaux types de données L’opérateur . sera très souvent utilisé dans la suite de ce livre. Il permet d’accéder aux membres d’une structure, mais également aux membres d’une classe que nous découvrirons plus tard. Initialiser une structure Dans l’exemple précédent, nous avons affecté une valeur à chacun des membres de la structure. aujourdHui.jour = 31; aujourdHui.mois = 7; aujourdHui.annee = 2026; Lorsque les valeurs sont connues dès la création de la variable, il est possible d’initialiser directement tous les membres de la structure. Date aujourdHui = {31, 7, 2026}; std::cout << aujourdHui.jour << « / » << aujourdHui.mois << « / » << aujourdHui.annee << std::endl; Les valeurs placées entre accolades sont affectées aux membres de la structure dans l’ordre de leur Date aujourdHui = {31, 7, 2026}; aujourdHui.jour = 31; aujourdHui.mois = 7; aujourdHui.annee = 2026; Cette écriture est plus concise lorsque toutes les valeurs sont connues au moment de la création de
Accueil
Créer de nouveaux types de données • Une structure peut être initialisée en une seule instruction grâce aux accolades. • Les valeurs sont affectées aux membres dans l’ordre de leur déclaration. Le nom d’une structure doit représenter clairement ce qu’elle modélise. Par exemple, les noms décrivent immédiatement le rôle de la structure. À l’inverse, des noms tels que n’apportent aucune information sur leur contenu et rendent le programme plus difficile à Il n’existe pas de règle imposée par le langage C++ concernant le nom des variables, des fonctions ou des types. Cependant, il est recommandé d’adopter une convention de nommage Dans cet ouvrage, nous utiliserons les conventions suivantes : • Les noms des structures et des classes commencent par une majuscule. • Les noms des variables et des fonctions commencent par une minuscule. • Les constantes sont écrites entièrement en majuscules. const double PI = 3.141592653589793; Ces conventions facilitent la lecture des programmes en permettant de distinguer immédiatement les différents éléments du code. Copie et affectation d’une structure Comme les types fondamentaux, une structure peut être copiée à l’aide de l’opérateur d’affectation.
Accueil
Créer de nouveaux types de données Date d1 = {31, 7, 2026}; std::cout << d2.jour << « / » << d2.annee << std::endl; copie automatiquement tous les membres de la structure d1 dans la structure d2. Elle est donc équivalente à d2.annee = d1.annee; Cette copie est réalisée automatiquement par le compilateur. Copie et affectation d’une structure Comme les types fondamentaux, une structure peut être copiée à l’aide de l’opérateur d’affectation. Date d1 = {31, 7, 2026}; std::cout << d2.jour << « / » << d2.annee << std::endl;
Accueil
Créer de nouveaux types de données copie automatiquement tous les membres de la structure d1 dans la structure d2. Elle est donc équivalente à d2.annee = d1.annee; Cette copie est réalisée automatiquement par le compilateur. Que va-t-il se passer ? Date d1 = {31, 7, 2026}; std::cout << d2.annee << std::endl; • Une structure est un véritable type de données. • Une simple affectation copie automatiquement tous les membres de la structure. • Il est important de ne pas confondre une copie avec un alias. Deux variables de type Date obtenues par une affectation sont indépendantes. À l’inverse, une référence est un alias : elle désigne exactement la même variable. Passer une structure à une fonction Une structure étant un véritable type de données, elle peut être transmise à une fonction exactement comme une variable de type int, double ou char. L’exemple suivant affiche le contenu d’une structure représentant une date.
Accueil
Créer de nouveaux types de données void afficher(Date d) std::cout << d.jour << « / » << d.annee << std::endl; Date aujourdHui = {31, 7, 2026}; afficher(aujourdHui); La fonction reçoit une copie de la structure. La variable d est donc indépendante de la variable aujourdHui. Que va-t-il se passer ? void modifier(Date d) Date aujourdHui = {31, 7, 2026}; modifier(aujourdHui); std::cout << aujourdHui.annee << std::endl; Retourner une structure Une fonction peut également retourner une structure. L’exemple suivant crée une date et la retourne au programme appelant. Date d = {31, 7, 2026};
Accueil
Créer de nouveaux types de données aujourdHui = creerDate(); std::cout << aujourdHui.jour << « / » << aujourdHui.mois << « / » << aujourdHui.annee << std::endl; La fonction creerDate() retourne une structure de type Date. Cette structure est ensuite copiée dans la variable aujourdHui. Quelle est la sortie du code suivant : Date d = {31,7,2026}; Date d1 = creerDate(); Date d2 = creerDate(); std::cout << d2.annee << std::endl; Dans la suite de cet ouvrage, nous utiliserons principalement la syntaxe using, tout en sachant que l’on rencontre encore très fréquemment typedef dans les bibliothèques et les programmes plus Il est parfois utile de donner un autre nom à un type existant. Pour cela, le langage C++ met à disposition le mot-clé typedef.
Accueil
Créer de nouveaux types de données typedef Date DateCalendrier; On peut alors déclarer une variable de la manière suivante : DateCalendrier aujourdHui; Le mot-clé typedef ne crée pas un nouveau type. Il définit simplement un autre nom (appelé alias) pour un type déjà existant. Depuis C++11, il est recommandé d’utiliser le mot-clé using, qui remplit le même rôle avec une syntaxe plus moderne. using DateCalendrier = Date; Cette écriture est également plus adaptée aux templates que nous étudierons plus loin dans Une énumération (enumeration) permet de définir un nouveau type dont les valeurs possibles sont limitées à une liste prédéfinie. Elle est particulièrement utile lorsque plusieurs valeurs symboliques représentent un même concept. On peut ensuite déclarer une variable de ce type. L’utilisation d’une énumération rend le programme plus lisible et limite les valeurs qu’une variable Depuis C++11, il existe également les enum class, qui offrent une meilleure sécurité de Dans un premier temps, nous utiliserons les énumérations classiques afin de nous concentrer sur les notions fondamentales.
Accueil
Créer de nouveaux types de données • Une énumération permet de définir un nouveau type dont les valeurs possibles sont choisies par le programmeur. Les structures permettent de regrouper plusieurs informations décrivant un même objet au sein d’un nouveau type de données. Dans ce chapitre, nous avons vu qu’une structure peut être : • transmise à une fonction ; • retournée par une fonction. Les structures constituent une étape importante dans l’apprentissage du C++. Elles permettent d’organiser les données de manière claire et de manipuler des objets plus riches que les types fondamentaux. Dans les chapitres suivants, nous verrons comment transmettre efficacement ces structures aux fonctions, puis comment les enrichir de fonctionnalités grâce à la programmation orientée objet. • Une structure permet de regrouper plusieurs variables sous un même type. • Une structure est un véritable type de données. • Une structure peut être copiée, transmise à une fonction et retournée par une fonction. • Les structures préparent naturellement l’étude des classes et de la programmation orientée objet.
Accueil
cpp-Chap10- Créer de nouveaux types de données
Accueil
Termes à ajouter au glossaire

Chapitre 11 - Passage des paramètres à une fonction

Chapitre 11 – Passage des paramètres à une fonction

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
11 Passage des paramètres 11.1 Passage des paramètres à une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . 11.2 Passage par valeur (passage par copie) . . . . . . . . . . . . . . . . . . . . . . . . . . . 11.5 Passage par référence constante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Passage des paramètres à une fonction
Accueil
Passage des paramètres Passage des paramètres à une fonction Jusqu’à présent, nous avons utilisé des fonctions dont les paramètres étaient principalement des variables de type int, double ou char. Nous avons également vu qu’il était possible de transmettre des tableaux, des chaînes de caractères et des structures. Cependant, toutes ces données ne sont pas transmises de la même manière. Le choix de la méthode de transmission d’un paramètre est très important. Il influence le fonctionnement de la fonction, les performances du programme et la possibilité ou non de modifier les variables du programme appelant. Dans ce chapitre, nous allons étudier les quatre principales façons de transmettre un paramètre à • le passage par valeur (passage par copie) ; • le passage par référence ; • le passage par référence constante ; • le passage par pointeur. Afin de mettre en évidence les différences entre ces méthodes, nous utiliserons tout au long de ce chapitre la même structure Date. Seule la manière de transmettre le paramètre changera. Passage par valeur (passage par copie) Lorsqu’un paramètre est transmis par valeur, la fonction reçoit une copie de la variable passée en Les modifications effectuées sur cette copie n’ont donc aucun effet sur la variable du programme Le programme suivant illustre ce principe. Passage des paramètres à une fonction void modifier(Date d) Date aujourdHui = {31, 7, 2026}; modifier(aujourdHui); std::cout << aujourdHui.annee << std::endl; Program ended with exit code: 0 Bien que la fonction modifie le membre annee, la variable aujourdHui n’est pas modifiée. En effet, la fonction ne travaille pas directement sur la variable du programme appelant. Elle reçoit une copie de cette variable.
Accueil
Passage des paramètres à une fonction Fig. 11.1 – Passage par valeur ou encore, passage par copie. • Lors d’un passage par valeur, la fonction reçoit une copie du paramètre. • Toute modification effectuée dans la fonction concerne uniquement cette copie et n’a aucun effet sur la variable du programme appelant. Le passage par valeur est particulièrement adapté aux types fondamentaux tels que En effet, ces types occupent très peu de mémoire et leur copie est extrêmement rapide.
Accueil
Passage des paramètres à une fonction Il est utilisé lorsque la fonction doit travailler sur une copie indépendante du paramètre afin de ne pas modifier la variable du programme appelant. Passage par référence Lorsqu’un paramètre est transmis par référence, la fonction ne reçoit pas une copie de la variable, mais un alias de celle-ci. Les modifications effectuées dans la fonction agissent donc directement sur la variable du programme Le programme suivant illustre ce principe. void modifier(Date& d) Date aujourdHui = {31, 7, 2026}; modifier(aujourdHui); std::cout << aujourdHui.annee << std::endl; Program ended with exit code: 0 Contrairement au passage par valeur, aucune copie de la structure n’est réalisée. Le paramètre d est un alias de la variable aujourdHui. Les deux noms désignent donc exactement la même structure en mémoire. Toute modification effectuée sur d modifie également aujourdHui.
Accueil
Passage des paramètres à une fonction • Lors d’un passage par référence, la fonction reçoit un alias du paramètre. • Aucune copie n’est réalisée. Toute modification effectuée dans la fonction agit directement sur la variable du programme appelant. Le passage par référence est recommandé lorsqu’une fonction doit modifier une variable du Comme aucune copie n’est réalisée, cette méthode est également plus efficace pour les objets void modifier(Date& d) Date d1 = {31, 7, 2026}; std::cout << d1.annee << std::endl; Une référence reste une référence. Qu’elle soit déclarée dans une fonction ou dans le programme principal, elle constitue toujours un alias de la variable à laquelle elle est liée.
Accueil
Passage des paramètres à une fonction void modifier(int& w) std::cout << rd <<  »  » << &d << std::endl;  » << &rd << std::endl; Dans ce programme, le symbole & apparaît trois fois. A-t-il toujours la même signification ? La réponse est non. Déclare une référence. Déclare un paramètre transmis par référence. Donne l’adresse mémoire de la variable d. Table 11.1 – Les différentes significations du symbole &. Les opérateurs & et ∗ sont deux symboles du C++ dont la signification dépend du contexte dans lequel ils sont utilisés. Avec un peu de pratique, cette distinction devient rapidement Passage par référence constante Dans de nombreux cas, une fonction doit simplement consulter les informations d’une structure sans les modifier. Le passage par référence constante permet d’éviter une copie tout en garantissant que la fonction ne pourra pas modifier la variable du programme appelant.
Accueil
Passage des paramètres à une fonction void afficher(const Date& d) std::cout << d.jour << « / » << d.annee << std::endl; Date aujourdHui = {31, 7, 2026}; afficher(aujourdHui); Program ended with exit code: 0 Fig. 11.2 – Le compilateur véri- fie que les règles du langage C++ sont respectées. Toute tentative de modifier une référence constante est détectée lors de la compilation, avant même que le programme ne • Le passage par référence constante permet de consulter un objet sans le copier et sans risquer de le modifier. • Cette méthode est généralement recommandée lorsqu’une fonction doit uniquement lire les informations contenues dans un objet.
Accueil
Passage des paramètres à une fonction • Le passage par référence constante est particulièrement adapté aux objets volumineux qui ne doivent pas être modifiés. • Il évite la création d’une copie tout en protégeant la variable du programme appelant contre toute modification accidentelle. • Le mot-clé const permet au programmeur d’exprimer clairement son intention. Le compilateur vérifie ensuite que cette intention est respectée. • Pourquoi utiliser const alors que je sais déjà que je ne vais pas modifier le paramètre ? Parce que le code est souvent relu, modifié et complété plusieurs mois ou plusieurs années après son écriture. En ajoutant const, le programmeur fournit une garantie au compilateur, qui vérifiera automatiquement que cette garantie est toujours respectée. • Le compilateur ne vérifie pas si la valeur de l’objet change. Il vérifie si le programme tente d’effectuer une écriture dans un objet déclaré constant. Dans le doute, si une fonction doit simplement consulter un objet, le passage par référence constante est souvent le meilleur choix. Passage par pointeur Comme une référence, un pointeur permet à une fonction d’agir directement sur la variable du programme appelant. La différence est que la fonction reçoit l’adresse de cette variable et non un alias. Le programme suivant illustre ce principe. void modifier(Date* d) // ou (*d).annee = 2030 Date aujourdHui = {31, 7, 2026}; modifier(&aujourdHui); std::cout << aujourdHui.annee << std::endl;
Accueil
Passage des paramètres à une fonction Program ended with exit code: 0 Le paramètre d est un pointeur. Il contient donc l’adresse de la variable aujourdHui. Pour appeler la fonction, il est nécessaire de transmettre cette adresse à l’aide de l’opérateur &. À l’intérieur de la fonction, l’opérateur −> permet d’accéder directement aux membres de la structure Le passage par pointeur est utilisé lorsque la fonction doit manipuler explicitement l’adresse Nous verrons plus loin que cette méthode est également indispensable pour les tableaux, les allocations dynamiques et certaines structures de données comme les listes chaînées et les Passage par référence Passage par pointeur Appel de la fonction Déclaration de la fonction Table 11.2 – Comparaison entre le passage par référence et le passage par pointeur. Tableau récapitulatif Mode de transmission Modifie l’original Passage par référence Passage par pointeur Paramètre de la fonction Table 11.3 – Comparaison des différents modes de transmission des paramètres.
Accueil
Passage des paramètres à une fonction Mode de transmission Utilisation recommandée Types fondamentaux ou lorsqu’une copie indépendante est souhaitée. Passage par référence Lorsque la fonction doit modifier l’objet. Lorsque la fonction consulte un objet sans le modifier. C’est souvent le meilleur choix pour les objets volumineux. Passage par pointeur Lorsque la fonction manipule explicitement une adresse (tableaux, allocation dynamique, listes chaînées, arbres, objet éventuellement absent, etc.). Table 11.4 – Choix du mode de transmission des paramètres. Le choix du mode de transmission d’un paramètre fait partie de la conception d’une fonction. Il n’existe pas de mode de transmission meilleur que les autres. Le choix dépend du rôle que doit jouer le paramètre dans la fonction. Le choix du mode de transmission d’un paramètre constitue une décision importante lors de la conception d’une fonction. Selon les besoins, il est possible de transmettre une copie de la variable, un alias de celle-ci ou encore son adresse mémoire. Chaque solution possède ses avantages et répond à un objectif bien précis. Le programmeur ne choisit donc pas un mode de transmission au hasard. Il choisit celui qui exprime le mieux le rôle que devra jouer le paramètre dans la fonction. Les tableaux récapitulatifs présentés à la fin de ce chapitre pourront servir de guide lors de l’écriture de nouvelles fonctions. Une fonction bien conçue commence souvent par un bon choix de ses paramètres.
Accueil
cpp-Chap11- Passage des paramètres à une fonction
Accueil
Termes à ajouter au glossaire

Partie II : La programmation orientée objet - Templates

La programmation orientée objet – POO

Chapitre 1 - Les classes

Chapitre 1 – Les classes

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
1.6 Comment une méthode sait-elle sur quel objet elle travaille ? . . . . . . . . . . . . . . 1.12 La liste d’initialisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie II
Accueil
Organiser un projet C++ Jusqu’à présent, tous les programmes de cet ouvrage étaient contenus dans un seul fichier source. Cette organisation est parfaitement adaptée à de petits exemples, mais elle devient rapidement difficile à maintenir lorsque les programmes prennent de l’importance. Imaginons par exemple un projet contenant plusieurs milliers de lignes de code. Regrouper toutes les fonctions, toutes les structures et toutes les classes dans un unique fichier rendrait le programme difficile à lire, à modifier et à maintenir. Pour cette raison, un projet C++ est généralement réparti entre plusieurs fichiers, chacun ayant un Dans cette organisation, • main.cpp contient la fonction main() ainsi que le déroulement général du programme. • Date.h contient la déclaration de la classe Date. • Date.cpp contient la définition des fonctions membres de cette classe. Cette organisation n’est pas imposée par le langage C++, mais elle est devenue un standard dans la plupart des projets, car elle facilite la lecture, la maintenance et la réutilisation du Les fichiers d’en-tête Lorsqu’un projet comporte plusieurs fichiers source, ceux-ci doivent pouvoir connaître les structures, les classes et les fonctions définies dans les autres fichiers. Les fichiers d’en-tête, dont l’extension est généralement .h, ont précisément pour rôle de mettre ces informations à disposition des autres fichiers du projet. 2026 – C++ Partie II Un fichier d’en-tête contient principalement des déclarations, c’est-à-dire les informations nécessaires pour utiliser un type ou une fonction, sans en donner les détails d’implémentation. Par exemple, le fichier Date.h peut contenir la déclaration suivante : void afficher() const; À ce stade, seule la déclaration de la fonction afficher() est connue. Son code sera écrit dans un fichier source distinct nommé Date.cpp. Le fichier main.cpp connaît ainsi l’existence de la classe Date grâce au fichier Date.h. Lorsque la fonction afficher() est appelée, son code est recherché dans le fichier Date.cpp lors de l’édition des liens 1 . La réalisation d’un programme C++ se déroule en plusieurs étapes : • les fichiers source (.cpp) sont d’abord compilés individuellement ; • cette compilation produit des fichiers objets ; • ces fichiers objets sont ensuite réunis lors de l’édition des liens (linking) afin de produire le programme exécutable. Dans ce chapitre, nous utiliserons simplement ce mécanisme sans entrer dans ses détails. Nous reviendrons plus loin sur chacune de ces étapes et apprendrons notamment à automatiser la compilation à l’aide de Makefiles. 1. L’édition des liens (ou linking en anglais) est l’étape qui suit la compilation. Elle consiste à rassembler les différents fichiers objets produits par le compilateur afin de former un unique programme exécutable. Nous étudierons ce mécanisme plus en détail dans un chapitre consacré à la compilation des projets C++. La réalisation d’un programme C++ se déroule en plusieurs étapes : • les fichiers source (.cpp) sont d’abord compilés individuellement ; • cette compilation produit des fichiers objets ; • ces fichiers objets sont ensuite réunis lors de l’édition des liens (linking) afin de produire le programme Dans ce chapitre, nous utiliserons simplement ce mécanisme sans entrer dans ses détails. Nous reviendrons plus loin sur chacune de ces étapes et apprendrons notamment à automatiser la compilation à l’aide de Makefiles.
Accueil
2026 – C++ Partie II • Un fichier d’en-tête contient les déclarations nécessaires pour utiliser une structure, une classe ou une fonction. • Les définitions correspondantes sont généralement placées dans un fichier source (.cpp) Les fichiers d’en-tête ne sont pas réservés aux classes. Ils peuvent également contenir des déclarations de fonctions, de structures, d’énumérations, de constantes ou d’alias de types (typedef ou using). Les fichiers source, dont l’extension est généralement .cpp, contiennent les définitions des fonctions et des méthodes déclarées dans les fichiers d’en-tête. Ils renferment donc le code réellement exécuté par le programme. Le fichier Date.cpp peut par exemple contenir la définition de la fonction afficher() déclarée dans void Date::afficher() const std::cout << « … » << std::endl; La déclaration de la fonction est placée dans Date.h, tandis que son implémentation est écrite dans Cette séparation facilite la lecture du programme et permet de répartir le travail entre plusieurs • Les fichiers d’en-tête (.h) contiennent principalement les déclarations. • Les fichiers source (.cpp) contiennent les définitions correspondantes. Notre première classe Jusqu’à présent, nous avons utilisé des structures pour regrouper des données et des fonctions indépendantes pour les manipuler. Par exemple, nous avons défini une structure Date ainsi qu’une fonction permettant de l’afficher.
Accueil
2026 – C++ Partie II void afficher(const Date& d) std::cout << d.jour << « / » << d.annee << std::endl; Cette organisation fonctionne parfaitement. Cependant, une question se pose naturellement. Pourquoi la fonction afficher() est-elle écrite à l’extérieur de la structure alors qu’elle manipule exclusivement des objets de type Date ? Ne serait-il pas plus naturel de regrouper les données et les fonctions qui les manipulent au même endroit ? Les fonctions afficher(), ajouterJour() ou encore estBissextile() manipulent toutes des objets de type Date. Où serait-il le plus logique de les placer ? La réponse à cette question est l’une des idées fondamentales de la programmation orientée objet. Une classe permet de regrouper dans une même entité les données et les fonctions qui les manipulent. Une classe peut ainsi être considérée comme une évolution naturelle des structures étudiées jusqu’à void afficher() const; On remarque que la fonction afficher() fait désormais partie de la classe. Voilà les 3 fichiers du programme que l’on va étudier à présent.
Accueil
2026 – C++ Partie II void afficher() const; Code 1.2 – Date.cpp void Date::afficher() const std::cout << jour << « / » << annee << std::endl; Code 1.3 – main.cpp aujourdHui.jour = 31; aujourdHui.mois = 7; aujourdHui.annee = 2026; aujourdHui.afficher(); Vous pouvez télécharger le fichiers zip contenant les codes ici : Date_01. Une fonction appartenant à une classe est appelée une méthode (ou fonction membre). La classe Date déclare une méthode chargée d’afficher son contenu, la méthode afficher().
Accueil
2026 – C++ Partie II void afficher() const; La méthode afficher() est uniquement déclarée dans le fichier Date.h. Son implémentation est écrite dans le fichier Date.cpp. On remarque que la définition de la méthode est précédée de Date::. Cette écriture indique au compilateur que la méthode afficher() appartient à la classe Date. La définition de la méthode est implémentée dans le fichier Date.cpp comme on le voit ci-dessus. L’appel de la méthode s’effectue directement à partir de l’objet grâce à l’opérateur .. aujourdHui.afficher(); Cette écriture est souvent plus naturelle que l’appel d’une fonction externe, car la méthode appartient directement à l’objet qu’elle manipule. L’opérateur . était déjà utilisé pour accéder aux données membres d’une structure. Avec les classes, il permet également d’appeler les méthodes de l’objet. • Une méthode est une fonction appartenant à une classe. • Elle est appelée directement à partir d’un objet à l’aide de l’opérateur .. Sans exécuter le programme, indiquez le rôle de chacun des deux opérateurs . dans les instructions suivantes. aujourdHui.jour = 31; aujourdHui.afficher(); 1. Le premier . permet l’accès à une donnée membres, 2. et le second, appelle une méthode. Une question reste cependant en suspens : comment la méthode afficher() sait-elle sur quel objet elle travaille ? Nous répondrons à cette question dans la section suivante. Comment une méthode sait-elle sur quel objet elle travaille ? Dans le programme précédent, la méthode afficher() est appelée à partir de l’objet aujourdHui :
Accueil
2026 – C++ Partie II aujourdHui.afficher(); Pourtant, dans le fichier Date.cpp, le nom aujourdHui n’apparaît nulle part. void Date::afficher() const std::cout << jour << « / » << annee << std::endl; Comment la méthode sait-elle alors quelles valeurs afficher ? Lorsqu’une méthode est appelée, le compilateur lui transmet automatiquement l’objet sur lequel elle Dans notre exemple, la méthode reçoit implicitement l’objet aujourdHui. C’est pourquoi elle peut accéder directement aux membres jour, mois et annee, sans qu’il soit nécessaire d’écrire : Cette transmission est entièrement automatique. Le programmeur n’a rien de particulier à Nous découvrirons plus loin que le langage C++ met en œuvre ce mécanisme à l’aide d’un pointeur implicite nommé this. Que se passerait-il si l’on écrivait le programme suivant ? aujourdHui.afficher(); La méthode afficher() est pourtant unique. Comment peut-elle afficher deux objets différents ? Parce que le compilateur transmet automatiquement l’objet concerné à chaque appel. La première version de notre classe Date fonctionne correctement. Ses données sont cependant accessibles librement depuis l’extérieur de la classe. Par exemple, le programme suivant est parfaitement autorisé :
Accueil
2026 – C++ Partie II aujourdHui.annee = 2026; Le compilateur n’émet pourtant aucun message d’erreur. Pourtant, une date telle que le 45e jour du mois de −8 n’a aucun sens.Le problème est que n’importe quelle partie du programme peut modifier directement les données de l’objet, sans aucun contrôle. Comment empêcher une partie du programme d’écrire des valeurs incohérentes dans un objet tout en autorisant la lecture ou la modification de ces valeurs lorsque cela est justifié ? La programmation orientée objet apporte une solution simple à ce problème : rendre les données privées. Les membres placés après le mot-clé private ne sont accessibles qu’à l’intérieur de la classe. void afficher() const; aujourdHui.jour = 31; Cette fois, le compilateur refuse la compilation. Les données de l’objet ne sont plus accessibles directement depuis l’extérieur de la classe. Le mot-clé private ne protège pas les données contre le programmeur. Il protège les données contre les erreurs de programmation en imposant que toute modification passe par les méthodes de la classe. Une question apparaît immédiatement. Si les données sont privées, comment créer une date, consulter ses valeurs ou les modifier ? La réponse est simple : seules les méthodes de la classe sont autorisées à accéder directement aux données privées. Nous allons découvrir progressivement les différents mécanismes permettant de le faire, en commençant par les constructeurs. Depuis que les données de notre classe sont devenues privées, les instructions suivantes ne sont plus
Accueil
2026 – C++ Partie II aujourdHui.jour = 31; aujourdHui.mois = 7; aujourdHui.annee = 2026; Le compilateur refuse désormais toute tentative de modification directe des membres de la classe. Une question se pose alors naturellement : Comment créer un objet correctement initialisé si ses données sont privées ? La réponse est apportée par les constructeurs. Un constructeur est une méthode particulière appelée automatiquement lors de la création d’un objet. Son rôle est d’initialiser les données de cet objet afin qu’il soit immédiatement utilisable. Date(int j, int m, int a); void afficher() const; Contrairement aux autres méthodes, le constructeur porte exactement le même nom que la classe. Il ne possède aucun type de retour, pas même void. Une implémentation possible est : Date::Date(int j, int m, int a) L’utilisation du constructeur est : Date aujourdHui(31, 7, 2026); aujourdHui.afficher();
Accueil
2026 – C++ Partie II On remarque que le constructeur n’est jamais appelé explicitement. L’instruction Date aujourdHui(31, 7, 2026); crée l’objet aujourdHui puis appelle automatiquement le constructeur correspondant. Un constructeur est exécuté une seule fois : au moment de la création de l’objet. Par la suite, les données pourront être consultées ou modifiées à l’aide des méthodes prévues Le constructeur est une méthode particulière qui : • porte le même nom que la classe ; • ne possède aucun type de retour ; • est appelé automatiquement lors de la création de l’objet ; • initialise les données de celui-ci. Depuis que les données de notre classe sont devenues privées, les instructions suivantes ne sont plus std::cout << aujourdHui.jour << std::endl; Le compilateur refuse tout accès direct aux membres privés de la classe. Il est donc nécessaire de fournir un moyen de consulter ces informations sans pour autant rendre les données accessibles à l’extérieur de la classe. Pour cela, on utilise des méthodes particulières appelées getters. Date(int j, int m, int a); int getJour() const; int getMois() const; int getAnnee() const;
Accueil
2026 – C++ Partie II dont l’implémentation Date_02.cpp est : #include « Date_02.h » Date::Date(int j, int m, int a) int Date::getJour() const int Date::getMois() const int Date::getAnnee() const #include « Date_02.h » Date aujourdHui(31, 7, 2026); std::cout << aujourdHui.getJour() << « / » << aujourdHui.getMois() << « / » << aujourdHui.getAnnee() << std::endl; Les getters permettent de consulter les données d’un objet sans donner un accès direct à ses membres privés. Ainsi, la classe conserve le contrôle de ses données tout en autorisant leur lecture. Les getters ne modifient pas l’objet. Ils sont donc généralement déclarés avec le mot-clé const.
Accueil
2026 – C++ Partie II • Un getter est une méthode permettant de lire la valeur d’un membre privé. • En règle générale, un getter : • retourne une valeur ; • ne modifie jamais l’objet ; • est déclaré const. Pourquoi le programme suivant est-il refusé par le compilateur ? Date aujourdHui(31,7,2026); std::cout << aujourdHui.jour; Quelle méthode faut-il utiliser à la place ? aujourdHui.getJour() Les préfixes get et set ne sont pas des mots-clés du langage C++. Il s’agit simplement d’une convention largement utilisée. Le compilateur accepte n’importe quel nom de méthode. Toutefois, employer ces préfixes rend le code plus clair et plus facilement compréhensible par les autres programmeurs. Les getters permettent de consulter les données d’un objet. Ils ne permettent cependant pas de les modifier. Pour autoriser une modification tout en conservant les données privées, on utilise des méthodes particulières appelées setters. Un setter reçoit généralement une nouvelle valeur en paramètre puis la stocke dans le membre correspondant de la classe. Le fichier d’en-tête devient Date(int j, int m, int a); int getJour() const; int getMois() const;
Accueil
2026 – C++ Partie II int getAnnee() const; void setJour(int j); void setMois(int m); void setAnnee(int a); void afficher() const; L’implémentation des setters est void Date::setJour(int j) void Date::setMois(int m) void Date::setAnnee(int a) Date aujourdHui(31, 7, 2026); aujourdHui.setJour(1); aujourdHui.setMois(8); aujourdHui.setAnnee(2026); aujourdHui.afficher(); Les setters permettent de modifier les données d’un objet sans donner un accès direct à ses membres privés. Toute modification passe donc par une méthode de la classe. Pour l’instant, nos setters se contentent de recopier la valeur reçue dans le membre correspondant. Ils semblent donc peu différents d’une affectation directe. Leur véritable intérêt apparaîtra dans la section suivante, lorsque nous ajouterons des contrôles empêchant l’introduction de valeurs incohérentes dans un objet.
Accueil
2026 – C++ Partie II • Un setter est une méthode permettant de modifier un membre privé. • Toute modification des données passe par les méthodes de la classe. • Les setters permettront par la suite de contrôler la validité des valeurs reçues. Encore une fois, les préfixes get et set ne sont pas des mots-clés du langage C++. Il s’agit simplement d’une convention de nommage très largement utilisée. Le compilateur accepte tout autre nom de méthode. Il est même possible de donner aux getters et aux setters le même nom que le membre auquel ils donnent accès : Le compilateur distingue sans ambiguïté les deux méthodes grâce à leur signature a . L’une ne reçoit aucun paramètre (getter), tandis que l’autre en reçoit un (setter). Il s’agit d’un exemple de surcharge de méthodes. Dans ce cours, nous utiliserons toutefois les préfixes get et set, car ils rendent immédiatement le rôle de chaque méthode plus explicite. a. La signature d’une fonction ou d’une méthode est constituée de son nom et de la liste ordonnée de ses paramètres. Le type de retour ne fait pas partie de la signature. Les instructions suivantes seront-elles acceptées ? Non, les deux fonctions ont la même signature. Le type de retour ne fait pas partie de la
Accueil
2026 – C++ Partie II Pourquoi le type de retour ne fait-il pas partie de la signature d’une fonction ? Considérons les deux fonctions suivantes : Supposons ensuite les instructions suivantes : double y = valeur(); À première vue, ces deux appels semblent parfaitement valides. Pourtant, le compilateur les refuse. En effet, une valeur de type int peut être convertie en double, et une valeur de type double peut également être convertie en int. Le type de retour ne permet donc pas de déterminer avec certitude quelle fonction doit être appelée. C’est pourquoi le type de retour ne fait pas partie de la signature d’une fonction. Un langage de programmation doit toujours permettre au compilateur de choisir une fonction de manière Notre setter accepte encore des valeurs absurdes. Peut-on empêcher qu’une date devienne le 42 janvier ou le mois 18 ? Valider les données Les setters que nous venons d’écrire permettent de modifier les données d’un objet tout en conservant les membres privés. Pour l’instant, ils se contentent toutefois d’une simple affectation : void Date::setJour(int j) Rien n’empêche donc d’écrire : Date aujourdHui(31, 7, 2026); aujourdHui.setJour(42); Le compilateur accepte parfaitement ce programme alors que le jour 42 n’existe dans aucun calendrier. Notre classe ne garantit donc pas encore la cohérence des données qu’elle contient. Pour remédier à ce problème, il suffit d’effectuer un contrôle avant de modifier le membre privé.
Accueil
2026 – C++ Partie II void Date::setJour(int j) if (j >= 1 && j <= 31) Si la valeur proposée est comprise entre 1 et 31, le membre est modifié. Dans le cas contraire, la modification est simplement ignorée. Notre setter vérifie uniquement que le jour est compris entre 1 et 31. Il n’empêche pas encore des dates telles que : • 29 février 2023. La validation complète d’une date est plus délicate et nécessitera plusieurs contrôles supplémentaires. Nous comprenons maintenant l’intérêt des membres privés. Si les données étaient publiques, n’importe quelle partie du programme pourrait leur attribuer une valeur incohérente. En imposant que toute modification passe par les setters, la classe conserve le contrôle de son propre état. La liste d’initialisation Jusqu’à présent, nous avons écrit notre constructeur de la manière suivante : Date::Date(int j, int m, int a) Cette écriture est parfaitement correcte et sera acceptée par tous les compilateurs C++. Il existe toutefois une manière plus naturelle d’initialiser les membres d’un objet : la liste d’initialisation. La liste d’initialisation s’écrit entre la déclaration du constructeur et son corps : Date::Date(int j, int m, int a) Chaque membre est directement initialisé avec la valeur correspondante. Le corps du constructeur peut alors rester vide lorsque aucune autre opération n’est nécessaire.
Accueil
2026 – C++ Partie II La liste d’initialisation construit directement les membres de l’objet. Cette écriture est généralement préférée en C++, car elle est plus naturelle et devient indispensable pour certains types de données que nous rencontrerons plus loin. Pour des types simples comme int, les deux écritures produisent généralement le même En revanche, certains membres ne peuvent être initialisés que par une liste d’initialisation, notamment les constantes (const) et les références. Nous reviendrons sur ces cas particuliers plus loin dans cet ouvrage. Pour des types simples comme int, les deux écritures produisent généralement le même En revanche, certains membres ne peuvent être initialisés que par une liste d’initialisation, notamment les constantes (const) et les références. Nous reviendrons sur ces cas particuliers plus loin dans cet ouvrage. Vous pouvez télécharger le fichiers zip contenant les codes vu jusqu’à présent, ici : Date_02.
Accueil
P2-Chap-01- Les classes
Accueil
Termes à ajouter au glossaire

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Date_01
Classe Date avec fichiers .h, .cpp et Makefile.
Date_02
Classe Date avec constructeur, setters, getters, fichiers .h, .cpp et Makefile.
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Chapitre 2 - L'encapsulation

Chapitre 2 – L’encapsulation

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
2.2 Les limites des membres publics . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3 Protéger la représentation interne . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4 Faut-il toujours écrire des setters ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5 Additionner deux vecteurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6 Calculer la norme d’un vecteur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.7 Calculer le produit scalaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.8 Obtenir un vecteur unitaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.10 Exercices du chapitre 12 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie II
Accueil
Dans ce chapitre, nous allons commencer à construire une bibliothèque de calcul vectoriel 1 . Nous l’enrichirons progressivement à mesure que nous découvrirons de nouvelles possibilités du langage Dans un repère du plan, un vecteur est déterminé par deux composantes. Par exemple, La première composante est associée à l’axe horizontal et la seconde à l’axe vertical 2 . Une première classe permettant de représenter ce vecteur pourrait être écrite de la manière suivante : La classe contient deux données membres, x et y, qui représentent les deux composantes du vecteur. Elles sont placées dans la partie publique de la classe et sont donc directement accessibles depuis Nous pouvons ainsi créer un objet représentant le vecteur (3, 4) : L’objet u représente maintenant le vecteur 1. Une bibliothèque est un ensemble de fonctions, de classes, de structures et de constantes conçues pour être réutilisées dans plusieurs programmes. Ces éléments peuvent être regroupés dans un espace de noms. Les déclarations nécessaires à leur utilisation sont généralement placées dans un ou plusieurs fichiers d’en-tête, inclus à l’aide de la 2. Dans ce chapitre, nous travaillerons dans le plan vectoriel euclidien muni d’une base orthonormée. Ce choix nous permettra notamment d’exprimer simplement la norme et le produit scalaire à partir des composantes des vecteurs. 2026 – C++ Partie II Toutes les valeurs réelles peuvent être utilisées comme composantes d’un vecteur. Contrairement à une date, un vecteur ne risque donc pas de contenir un mois égal à 15 ou un jour Dans ce cas, pourquoi ne pas simplement laisser les membres x et y publics ? Les limites des membres publics Dans la première version de notre classe, les membres x et y sont intentionnellement publics. Toutes les parties du programme peuvent donc y accéder directement. Cela permet de consulter facilement les composantes d’un vecteur : std::cout << u.x << std::endl; std::cout << u.y << std::endl; Mais cela permet également de les modifier à tout moment : La valeur 12.0 est une composante parfaitement valide. Le problème n’est donc pas que cette instruction rende nécessairement le vecteur incohérent. Le problème est que la classe ne contrôle pas les modifications apportées à ses données. Une modification accidentelle est alors impossible à distinguer d’une modification volontaire. Dans la classe Date étudiée au chapitre précédent, les membres privés permettaient notamment d’empêcher l’introduction de valeurs impossibles. L’encapsulation ne sert cependant pas uniquement à valider des données. Elle permet également à une classe de contrôler la manière dont son état est consulté et modifié. Les membres publics créent une autre difficulté. Le programme qui utilise la classe dépend directement des noms et de l’organisation de ses données. Si nous décidons un jour de renommer les membres ou de représenter les vecteurs autrement, toutes les instructions qui accèdent directement à x et y devront être retrouvées et modifiées. Notre classe ne propose encore aucune opération vectorielle. Par exemple, le calcul de la norme doit être effectué directement par le programme qui utilise le vecteur :
Accueil
2026 – C++ Partie II std::sqrt(u.x * u.x + u.y * u.y); Le programme doit donc connaître à la fois la représentation du vecteur et les calculs à effectuer sur ses composantes. La classe se contente pour l’instant de regrouper deux nombres. Les membres x et y constituent la représentation interne du vecteur. Les opérations que la classe mettra à la disposition du programme constitueront son interface publique. • Des membres publics peuvent être consultés et modifiés depuis toutes les parties du • Le programme devient alors directement dépendant de la représentation interne de la • L’interface publique regroupe les opérations proposées pour utiliser les objets de la classe. Nous souhaitons maintenant rendre les membres x et y privés. Comment pourrons-nous alors : • fournir les deux composantes lors de la création d’un vecteur ? • consulter ensuite ces composantes depuis la fonction main() ? Protéger la représentation interne Pour empêcher l’accès direct à la représentation interne du vecteur, nous plaçons les membres x et y dans la partie privée de la classe. Nous devons cependant conserver un moyen de fournir les composantes lors de la création du vecteur et de les consulter par la suite. Nous utiliserons pour cela un constructeur et deux getters. Code 2.1 – Vecteur2D.h Vecteur2D(double xInitial, double yInitial);
Accueil
2026 – C++ Partie II double getX() const; double getY() const; Le constructeur reçoit les deux composantes et initialise les membres privés à l’aide d’une liste Code 2.2 – Vecteur2D.cpp #include « Vecteur2D.h » Vecteur2D::Vecteur2D( double Vecteur2D::getX() const double Vecteur2D::getY() const Nous pouvons maintenant créer un vecteur en fournissant ses deux composantes au constructeur : Vecteur2D u(3.0, 4.0); Dès sa création, l’objet u représente donc le vecteur (3, 4). Il n’est plus nécessaire d’affecter séparément ses deux composantes. En revanche, l’accès direct à un membre privé est maintenant refusé par le compilateur : std::cout << u.x << std::endl; // Erreur Pour consulter les composantes, il faut passer par les getters appartenant à l’interface publique de std::cout << u.getX() << std::endl; std::cout << u.getY() << std::endl;
Accueil
2026 – C++ Partie II Les getters peuvent accéder directement aux membres x et y, car ils appartiennent à la classe Ils sont déclarés avec le mot-clé const, car ils consultent le vecteur sans le modifier. L’instruction suivante est-elle acceptée par le compilateur ? Justifiez votre réponse. Non. La classe ne possède pas de constructeur sans paramètre. Les deux composantes doivent donc être fournies lors de la création du vecteur. • Les membres privés constituent la représentation interne du vecteur. • Le constructeur définit les composantes lors de la création de l’objet. • Les getters permettent de consulter les composantes sans donner un accès direct aux Puisque les membres x et y sont maintenant privés, faut-il nécessairement ajouter deux setters setX() et setY() pour pouvoir les modifier ? Faut-il toujours écrire des setters ? Dans le chapitre précédent, nous avons utilisé des setters pour modifier les membres privés de la classe Date. Nous pourrions appliquer la même solution à notre classe Vecteur2D : void setX(double nouveauX); void setY(double nouveauY); Leur implémentation ne présenterait aucune difficulté particulière. void Vecteur2D::setX(double nouveauX) void Vecteur2D::setY(double nouveauY)
Accueil
2026 – C++ Partie II Ces méthodes permettraient de modifier séparément les deux composantes du vecteur : Vecteur2D u(3.0, 4.0); Le vecteur u, initialement égal à (3, 4), deviendrait alors le vecteur (5, 7). Ces deux setters seraient parfaitement valides en C++ et toutes les valeurs reçues représenteraient encore des composantes Puisque les setters précédents sont valides et faciles à écrire, devons-nous nécessairement les ajouter à la classe ? Non. Une classe ne doit pas fournir automatiquement un setter pour chacun de ses membres privés. Son interface publique doit être choisie en fonction des opérations que nous souhaitons proposer. Une instruction telle que décrit une modification de la représentation interne du vecteur. Elle n’exprime pas directement une opération vectorielle telle qu’une addition ou une multiplication par un scalaire. Nous choisirons donc de ne pas ajouter les méthodes setX() et setY() à notre classe. Une fois construit, un vecteur pourra être consulté et utilisé pour produire de nouveaux vecteurs, mais son interface ne permettra pas de modifier séparément ses composantes. Les setters ne sont pas incorrects en eux-mêmes. Dans une autre bibliothèque, il pourrait être parfaitement justifié d’autoriser la modification séparée des composantes. Leur absence constitue ici un choix de conception adapté à l’interface mathématique que nous souhaitons construire. La présence d’un membre privé impose-t-elle la présence : Non dans les deux cas. Un membre privé ne doit posséder un getter ou un setter que si l’interface publique de la classe doit permettre l’opération correspondante.
Accueil
2026 – C++ Partie II Dans notre classe, les getters sont utiles pour consulter les composantes cartésiennes du vecteur. Les setters ne correspondent pas à une opération que nous souhaitons proposer. Rendre un membre privé, puis fournir systématiquement un getter et un setter, ne constitue pas à lui seul une bonne encapsulation. L’encapsulation demande également de réfléchir aux opérations que la classe doit réellement • Un membre privé ne possède pas obligatoirement un getter ou un setter. • L’interface publique doit proposer des opérations utiles et cohérentes. • Les setters sont un choix de conception et non une obligation. Si nous ne modifions pas directement les composantes de u, comment pourrons-nous représenter le résultat d’une opération telle que Additionner deux vecteurs L’addition de deux vecteurs s’effectue composante par composante. Si u + v = (x u + x v , y u + y v ). Nous allons ajouter à la classe une méthode qui reçoit un autre vecteur et retourne le résultat de Vecteur2D additionner(const Vecteur2D& autre) const; Cette déclaration contient deux utilisations du mot-clé const : • const Vecteur2D& autre indique que la méthode ne peut pas modifier le vecteur reçu en • le dernier const indique que la méthode ne peut pas modifier le vecteur qui l’appelle. La méthode peut être implémentée de la manière suivante :
Accueil
2026 – C++ Partie II Vecteur2D Vecteur2D::additionner(const Vecteur2D& autre) const return Vecteur2D( x + autre.x, y + autre.y); L’expression Vecteur2D(…) appelle le constructeur afin de créer le vecteur résultant. Celui-ci est ensuite retourné par la méthode. Nous pouvons maintenant additionner deux vecteurs : Vecteur2D u(3.0, 4.0); Vecteur2D v(1.0, 2.0); Vecteur2D w = u.additionner(v); Le vecteur w vaut alors Les vecteurs u et v ne sont pas modifiés. L’addition fait intervenir deux vecteurs, u et v. Pourquoi la méthode additionner() ne possède-t-elle qu’un seul paramètre explicite ? Le premier vecteur est l’objet qui appelle la méthode. Il est transmis implicitement à celle-ci. Seul le second vecteur doit apparaître dans la liste des paramètres. Dans l’implémentation de la méthode, pourquoi peut-on écrire alors que le membre x est privé ? Une méthode de la classe Vecteur2D peut accéder aux membres privés de tous les objets de cette classe, et pas uniquement à ceux de l’objet qui l’appelle. Le mot-clé private limite l’accès au niveau de la classe et non au niveau de chaque objet. Deux objets de la même classe ne possèdent pas des règles d’accès différentes.
Accueil
2026 – C++ Partie II Considérons maintenant la déclaration suivante : void additionner(const Vecteur2D& autre); Cette méthode aurait-elle le même comportement que la précédente ? Non. Comme elle ne retourne aucun vecteur, cette méthode devrait modifier l’objet qui l’appelle. Elle ne pourrait donc pas être déclarée const. Avec cette seconde conception, l’instruction modifierait directement u. Dans notre bibliothèque, nous avons choisi de conserver les deux vecteurs additionnés et de retourner un troisième vecteur contenant le résultat. Les objets d’une même classe n’ont pas de membres privés les uns par rapport aux autres. Une méthode de la classe peut donc accéder aux membres privés de toutes ses instances, à condition de disposer d’une référence ou d’un pointeur vers celles-ci. Ce droit d’accès n’implique cependant pas toujours un droit de modification. Dans notre méthode, le paramètre autre est déclaré const. La méthode peut donc lire autre.x et autre.y, mais elle ne peut pas modifier leurs valeurs. De même, le const placé à la fin de la déclaration interdit de modifier l’objet qui appelle la méthode. Cette distinction réapparaîtra lorsque nous étudierons la surcharge des opérateurs. Une addition écrite avec l’opérateur + retourne généralement un nouvel objet, tandis que l’opérateur += modifie son opérande de gauche. • Une méthode membre reçoit implicitement l’objet qui l’appelle. • Une méthode déclarée const ne modifie pas cet objet. • Une opération peut construire et retourner un nouvel objet sans modifier ses opérandes. La multiplication d’un vecteur par un scalaire doit-elle également modifier le vecteur existant, ou peut-elle retourner un nouveau vecteur ?
Accueil
2026 – C++ Partie II Calculer la norme d’un vecteur Avant de poursuivre, le lecteur est invité à compléter lui-même la classe Vecteur2D en lui ajoutant une méthode permettant de multiplier un vecteur par un scalaire. Cette méthode devra retourner un nouveau vecteur sans modifier celui qui l’appelle. Il s’agit d’un nombre réel positif ou nul. La méthode chargée de la calculer retournera donc un double norme() const; Cette méthode ne reçoit aucun paramètre, car elle calcule la norme du vecteur qui l’appelle. Elle est déclarée const, puisqu’elle ne modifie pas ce vecteur. La fonction std::sqrt() permet de calculer une racine carrée. Elle est déclarée dans le fichier d’en-tête <cmath>. #include « Vecteur2D.h » double Vecteur2D::norme() const return std::sqrt(x * x + y * y); Nous pouvons maintenant calculer la norme du vecteur (3, 4) : Vecteur2D u(3.0, 4.0); std::cout << u.norme() << std::endl; Le programme affiche : Pourquoi la méthode norme() retourne-t-elle un double plutôt qu’un objet de type
Accueil
2026 – C++ Partie II La norme d’un vecteur est un scalaire et non un vecteur. Quelle valeur la méthode norme() retourne-t-elle pour le vecteur nul (0, 0) ? Elle retourne 0, car • Une méthode peut retourner un type différent de celui de sa classe. • Une méthode qui utilise uniquement l’objet qui l’appelle ne nécessite aucun paramètre • Une méthode qui ne modifie pas cet objet doit être déclarée const. Comment une méthode pourrait-elle utiliser deux vecteurs sans les modifier et retourner un Calculer le produit scalaire Dans une base orthonormée, le produit scalaire des vecteurs u · v = xu x v + y u y v . Le résultat est un scalaire. La méthode retournera donc un double : double produitScalaire( const Vecteur2D& autre) const; Comme pour l’addition, le premier vecteur est l’objet qui appelle la méthode et le second est reçu en paramètre. Aucun des deux vecteurs ne doit être modifié. La méthode s’implémente directement à partir des composantes des deux vecteurs : double Vecteur2D::produitScalaire( const Vecteur2D& autre) const return x * autre.x + y * autre.y; Considérons les deux vecteurs
Accueil
2026 – C++ Partie II Le produit scalaire peut être calculé de la manière suivante : Vecteur2D u(3.0, 4.0); Vecteur2D v(1.0, 2.0); std::cout << resultat << std::endl; double resultat = u.produitScalaire(v); Le programme affiche : u · v = 3 · 1 + 4 · 2 = 11. Pourquoi la méthode additionner() retourne-t-elle un Vecteur2D, tandis que la méthode produitScalaire() retourne un double ? L’addition de deux vecteurs produit un nouveau vecteur, tandis que leur produit scalaire produit un Quel est le produit scalaire des vecteurs Que peut-on en déduire ? Le produit scalaire vaut 3 · (−4) + 4 · 3 = 0. Les deux vecteurs sont donc orthogonaux. La méthode produitScalaire() peut accéder directement aux membres privés de l’objet reçu en paramètre, car les deux objets appartiennent à la classe Vecteur2D. Le paramètre et l’objet qui appelle la méthode sont tous deux protégés contre les modifications par les deux utilisations du mot-clé const.
Accueil
2026 – C++ Partie II • Une méthode peut faire intervenir plusieurs objets de la même classe. • Le type retourné dépend de la nature mathématique du résultat. • Le produit scalaire de deux vecteurs orthogonaux est nul. La norme et la direction d’un vecteur peuvent être calculées à partir de ses composantes. Faut-il malgré tout les enregistrer comme de nouveaux membres de la classe ? Obtenir un vecteur unitaire La norme et la direction d’un vecteur peuvent être calculées à partir de ses composantes. Il n’est donc pas nécessaire de les enregistrer comme de nouveaux membres de la classe. Le vecteur unitaire associé à un vecteur non nul u est défini par Il possède la même direction et le même sens que u, mais sa norme est égale à 1. Nous ajoutons à la classe une méthode retournant ce nouveau vecteur : Vecteur2D vecteurUnitaire() const; La méthode commence par calculer la norme du vecteur. Elle utilise ensuite cette valeur pour diviser chacune de ses composantes. Vecteur2D Vecteur2D::vecteurUnitaire() const Cette méthode réutilise la méthode norme() déjà fournie par la classe. Il n’est donc pas nécessaire de réécrire le calcul de la norme. Une méthode peut appeler une autre méthode de la même classe. Comme norme() et vecteurUnitaire() sont toutes deux déclarées const, cet appel ne modifie pas le vecteur. Considérons à nouveau le vecteur Son vecteur unitaire est
Accueil
2026 – C++ Partie II Nous pouvons le calculer de la manière suivante : Vecteur2D u(3.0, 4.0); double longueur = u.norme(); Vecteur2D direction = u.vecteurUnitaire(); Les deux informations sont maintenant disponibles sans avoir été enregistrées comme membres supplémentaires : • longueur vaut 5.0 ; • direction représente le vecteur (0.6, 0.8). Pourquoi serait-il inutile d’ajouter un membre norme à la classe Vecteur2D ? La norme peut être calculée à partir des membres x et y. Son enregistrement créerait une information redondante qu’il faudrait maintenir en permanence en accord avec les composantes. La méthode vecteurUnitaire() ne peut être utilisée que si le vecteur n’est pas nul. En effet, le vecteur nul possède une norme égale à zéro et n’a pas de direction. Le calcul de son vecteur unitaire provoquerait donc une division par zéro. Peut-on associer un vecteur unitaire au vecteur nul (0, 0) ? Non. Le vecteur nul ne possède aucune direction et sa norme est égale à zéro. L’expression n’est donc pas définie. La condition exigeant que le vecteur soit non nul constitue une précondition de la méthode. Une précondition est une condition qui doit être satisfaite avant l’appel d’une fonction ou d’une méthode.
Accueil
2026 – C++ Partie II La précondition fait partie de l’interface de la méthode, même si elle n’apparaît pas dans sa signature. Le programme qui utilise la classe doit donc s’assurer que le vecteur n’est pas nul avant d’appeler vecteurUnitaire(). Nous verrons plus loin différents mécanismes permettant à une méthode de signaler automatiquement qu’une opération demandée ne peut pas être effectuée. • Une information facilement calculable ne doit pas nécessairement être enregistrée comme • Une méthode peut réutiliser les autres méthodes de sa classe. • Une précondition précise ce qui doit être vrai avant l’appel d’une méthode. • Le vecteur nul ne possède pas de vecteur unitaire. Dans ce chapitre, nous avons construit progressivement une classe représentant les vecteurs libres du plan. Cette classe regroupe les composantes du vecteur et les opérations mathématiques permettant La première version de la classe possédait deux membres publics : Cette représentation était simple, mais toutes les parties du programme pouvaient consulter et modifier directement les composantes. Le programme devenait également dépendant des noms et de l’organisation des données internes de la classe. Nous avons donc placé les composantes dans la partie privée et construit une interface publique composée d’un constructeur, de getters et d’opérations vectorielles. Vecteur2D(double xInitial, double yInitial); double getX() const; double getY() const; Vecteur2D additionner( const Vecteur2D& autre) const;
Accueil
2026 – C++ Partie II double norme() const; double produitScalaire( const Vecteur2D& autre) const; Vecteur2D vecteurUnitaire() const; L’encapsulation consiste à regrouper dans une classe l’état d’un objet et les opérations qui le manipulent, tout en contrôlant l’accès à sa représentation interne. • les membres x et y constituent la représentation interne du vecteur ; • la partie privée empêche leur accès direct depuis l’extérieur ; • la partie publique constitue l’interface proposée au programme ; • le constructeur définit les composantes lors de la création de l’objet ; • les getters permettent de consulter les composantes ; • les autres méthodes expriment des opérations mathématiques sur les vecteurs. Une interface publique ne doit pas être obtenue en ajoutant automatiquement un getter et un setter à chaque membre privé. Elle doit être conçue en fonction des opérations que la classe doit réellement proposer. C’est pourquoi notre classe possède des getters, mais aucun setter permettant de modifier séparément ses composantes. Nous avons également choisi de ne pas enregistrer la norme ou la direction comme de nouveaux membres. Ces informations peuvent être calculées à partir des composantes. Leur stockage créerait des données redondantes qu’il faudrait maintenir en permanence dans un état cohérent. L’encapsulation ne sert pas uniquement à empêcher l’introduction de valeurs invalides. Toutes les paires de réels représentent des composantes possibles pour un vecteur du plan. Elle permet surtout de séparer la manière dont un objet est représenté des opérations proposées Les méthodes qui ne modifient pas l’objet sont déclarées avec le mot-clé const. Lorsqu’un autre vecteur est reçu par référence constante, celui-ci peut être consulté sans être modifié. double produitScalaire( const Vecteur2D& autre) const; Dans cette déclaration, le premier const protège l’objet reçu en paramètre, tandis que le second protège l’objet qui appelle la méthode. Une méthode de la classe peut accéder aux membres privés de toutes les instances de cette classe. Ce droit d’accès ne supprime cependant pas les restrictions imposées par le mot-clé const. Certaines méthodes, comme additionner() et vecteurUnitaire(), construisent et retournent un nouvel objet sans modifier celui qui les appelle. D’autres, comme norme() et produitScalaire(),
Accueil
2026 – C++ Partie II retournent un scalaire. Le type retourné dépend donc de la nature mathématique du résultat. Enfin, l’interface d’une méthode ne se limite pas à sa signature. Elle comprend également son comportement et ses conditions d’utilisation. La méthode vecteurUnitaire() possède ainsi une précondition : le vecteur qui l’appelle doit être non nul. Le vecteur nul ne possède aucune direction. La méthode vecteurUnitaire() ne doit donc pas être appelée sur celui-ci. Le traitement automatique de cette situation sera étudié dans un chapitre consacré à la gestion des erreurs. Grâce à l’encapsulation, la représentation interne de la classe pourra évoluer sans nécessairement modifier les programmes qui l’utilisent. Par exemple, une future implémentation pourrait représenter un vecteur à l’aide de sa norme et de son angle. Tant que l’interface publique reste compatible, les getters et les méthodes peuvent effectuer les conversions nécessaires. • L’encapsulation sépare la représentation interne d’un objet de son interface publique. • Les membres privés ne sont accessibles qu’aux méthodes et aux autres membres autorisés • Une interface doit proposer des opérations utiles plutôt qu’un accès systématique à toutes les données. • Les informations facilement calculables ne doivent pas nécessairement être enregistrées • Une méthode déclarée const ne modifie pas l’objet qui l’appelle. • Une méthode peut construire et retourner un nouvel objet sans modifier ses opérandes. • Les préconditions font partie des règles d’utilisation d’une méthode.
Accueil
2026 – C++ Partie II Exercices du chapitre 12 Ces exercices sont basés sur la classe Vecteur2D qui vous pouvez télécharger ici
Accueil
P2-Chap-02- L’encapsulation
Accueil
Termes à ajouter au glossaire

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Vecteur2D
Encapsulation de la classe Vecteur2D
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Chapitre 3 - L'héritage

Chapitre 3 – L’héritage

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
3.2 Une classe commune : Vecteur ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3 Pourquoi ne pas utiliser l’héritage ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4 Un exemple plus naturel : les matrices . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.1 Stocker les coefficients . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.2 Comment stocker les coefficients . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.3 Construire et détruire une matrice . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.4 Repérer un coefficient dans la mémoire . . . . . . . . . . . . . . . . . . . . . . 3.5.5 Accéder aux coefficients . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.7 Modifier un coefficient . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.8 Multiplication matricielle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.10 Affectation par copie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.7 Une première classe dérivée : MatriceCarree . . . . . . . . . . . . . . . . . . . . . . 3.7.1 Une classe qui n’est pas encore utilisable . . . . . . . . . . . . . . . . . . . . . 3.8 Le constructeur d’une classe dérivée . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.8.1 La classe de base est construite en premier . . . . . . . . . . . . . . . . . . . 3.8.2 Utiliser les méthodes héritées . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.9 private et protected dans l’héritage . . . . . . . . . . . . . . . . . . . . . . . . . . 3.9.1 Tentative d’accès depuis la classe dérivée . . . . . . . . . . . . . . . . . . . . 3.9.2 Le niveau d’accès protected . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.9.3 Un cas où protected peut être utile . . . . . . . . . . . . . . . . . . . . . . . 3.10 Enrichir la classe dérivée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.10.1 Calcul du déterminant par développement de cofacteurs . . . . . . . . . . . . 3.10.2 Construire une matrice mineure . . . . . . . . . . . . . . . . . . . . . . . . . . 3.10.3 Calcul récursif du déterminant . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.11 Que reçoit réellement une classe dérivée ? . . . . . . . . . . . . . . . . . . . . . . . . . 3.11.1 Les fonctions membres . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.11.2 Les données de la classe de base . . . . . . . . . . . . . . . . . . . . . . . . . 3.11.3 Le cas particulier des constructeurs . . . . . . . . . . . . . . . . . . . . . . . . 3.11.4 Les opérations de copie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.12 Destruction d’un objet dérivé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.13 Une classe dérivée peut être utilisée comme sa classe de base . . . . . . . . . . . . . 3.14 L’héritage ne fonctionne pas dans les deux sens . . . . . . . . . . . . . . . . . . . . . 3.14.1 Le type et l’état sont deux notions différentes . . . . . . . . . . . . . . . . . . 3.14.2 Construire un nouvel objet spécialisé . . . . . . . . . . . . . . . . . . . . . . . 3.15 L’héritage sur plusieurs niveaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie II 3.15.1 Une chaîne de constructeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.15.2 Des fonctionnalités héritées sur plusieurs niveaux . . . . . . . . . . . . . . . . 3.15.3 Une propriété qui doit rester vraie . . . . . . . . . . . . . . . . . . . . . . . . . 3.16 Préserver la symétrie de la matrice . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
Dans le chapitre précédent, nous avons construit une classe représentant les vecteurs libres du plan (déplacements libres). Nous souhaitons maintenant étendre notre bibliothèque aux vecteurs de l’espace R3 . Dans une base orthonormée de l’espace R3 , un vecteur possède trois composantes. Par exemple, u = 3 = (2, 3, 6)T . Les deux premières composantes correspondent à celles d’un vecteur du plan. La troisième est associée à un nouvel axe, généralement noté z . En appliquant les principes d’encapsulation étudiés précédemment, nous pouvons construire une nouvelle classe possédant trois membres privés. Code 3.1 – Vecteur3D.h Vecteur3D(double xInitial, double getX() const; double getY() const; double getZ() const; 2026 – C++ Partie II Vecteur3D additionner(const Vecteur3D& autre) const; double norme() const; double produitScalaire(const Vecteur3D& autre) const; // Precondition : le vecteur ne doit pas etre nul. Vecteur3D vecteurUnitaire() const; Le constructeur initialise les trois composantes du vecteur. Vecteur3D::Vecteur3D(double xInitial, Les getters permettent de consulter séparément chacune des composantes. double Vecteur3D::getX() const double Vecteur3D::getY() const double Vecteur3D::getZ() const L’addition de deux vecteurs de l’espace s’effectue composante par composante : (x 1 , y 1 , z 1 )T + (x 2 , y 2 , z 2 )T = (x 1 + x 2 , y 1 + y 2 , z 1 + z 2 )T . La méthode additionner() construit et retourne le vecteur résultant. Vecteur3D Vecteur3D::additionner( const Vecteur3D& autre) const
Accueil
2026 – C++ Partie II La norme d’un vecteur de l’espace est donnée par La fonction std::sqrt() étant utilisée dans l’implémentation, le fichier Vecteur3D.cpp doit inclure le fichier d’en-tête <cmath>. double Vecteur3D::norme() const return std::sqrt(x * x + y * y + z * z); Dans une base orthonormée, le produit scalaire de deux vecteurs de l’espace est défini par u · v = xu x v + y u y v + zu z v . Son implémentation est donc : double Vecteur3D::produitScalaire( const Vecteur3D& autre) const Enfin, le vecteur unitaire associé à un vecteur non nul est obtenu en divisant chacune de ses composantes par sa norme. Vecteur3D Vecteur3D::vecteurUnitaire() const Comme pour la classe Vecteur2D, la méthode vecteurUnitaire() possède une précondition : le vecteur qui l’appelle ne doit pas être nul. Le fichier source complet commence donc par les inclusions suivantes : Code 3.2 – Vecteur3D.cpp #include « Vecteur3D.h » // Definitions des methodes de Vecteur3D
Accueil
2026 – C++ Partie II Nous pouvons maintenant créer et utiliser des vecteurs de l’espace : Vecteur3D u(2.0, 3.0, 6.0); Vecteur3D v(1.0, -1.0, 2.0); Vecteur3D somme = u.additionner(v); double longueur = u.norme(); double produit = u.produitScalaire(v); Les résultats obtenus sont : u + v = (3, 2, 8)T , u · v = 2 · 1 + 3 · (−1) + 6 · 2 = 11. Puisque Vecteur2D existe déjà, pourrait-on déclarer Vecteur3D comme une classe dérivée de Cette relation serait mathématiquement incorrecte. Un vecteur quelconque de l’espace n’est pas un vecteur du plan. Seuls les vecteurs dont la troisième composante est nulle peuvent être identifiés à des vecteurs du plan. Les classes Vecteur2D et Vecteur3D devront plutôt dériver d’une classe plus générale représentant la notion de vecteur. Comparez les classes Vecteur2D et Vecteur3D. Quelles parties de leur interface et de leur implémentation ont dû être réécrites ? Les deux classes possèdent notamment : • des composantes ; • un constructeur ; • une méthode d’addition ; • une méthode calculant la norme ; • une méthode calculant le produit scalaire ; • une méthode retournant un vecteur unitaire.
Accueil
2026 – C++ Partie II Seul le nombre de composantes et, par conséquent, le détail de certains calculs changent. La répétition observée entre les deux classes n’est pas une erreur commise par le programmeur. Elle fait apparaître une propriété commune qui n’avait pas encore été représentée dans notre bibliothèque : unVecteur2D et un Vecteur3D sont tous deux des vecteurs. • Un vecteur de l’espace possède trois composantes. • La classe Vecteur3D reprend de nombreuses caractéristiques de la classe Vecteur2D. • Un Vecteur3D n’est pas un cas particulier deVecteur2D. • Les deux classes peuvent être considérées comme des cas particuliers d’une classe plus Une classe commune : Vecteur ? Nous avons maintenant construit deux classes distinctes : • Vecteur2D, qui représente les vecteurs libres du plan ; • Vecteur3D, qui représente les vecteurs libres de l’espace. Ces deux classes ne décrivent pas le même ensemble mathématique. Un vecteur quelconque de R3 n’est pas un vecteur de R2 , et il serait donc incorrect de considérer Vecteur3D comme une extension En revanche, les deux classes représentent manifestement le même type d’objet mathématique général : un vecteur. Elles possèdent également plusieurs caractéristiques communes : • elles possèdent un certain nombre de composantes ; • elles peuvent être additionnées à un vecteur de même dimension ; • elles possèdent une norme ; • elles permettent de calculer un produit scalaire ; • elles peuvent être associées à un vecteur unitaire lorsqu’elles sont non nulles. La répétition observée entre les classes Vecteur2D et Vecteur3D nous indique donc qu’une notion plus générale manque encore dans notre bibliothèque. Nous allons essayer d’introduire une nouvelle classe appelée Vecteur, chargée de représenter les caractéristiques communes à tous les vecteurs que nous souhaitons manipuler. Les classes Vecteur2D et Vecteur3D deviendront alors deux spécialisations de cette classe générale.
Accueil
2026 – C++ Partie II La classe Vecteur ne représente pas ici un vecteur de dimension déterminée. Elle sert à exprimer ce que les classes Vecteur2D et Vecteur3D ont en commun. Nous verrons progressivement quelles informations et quelles opérations peuvent réellement être placées dans cette classe générale. Proposition pour la classe Vecteur : Code 3.3 – Vecteur.h int nombreComposantes; Vecteur(int nombre); int getNombreComposantes() const; La classe Vecteur contient pour l’instant une seule information commune aux différentes représentations : le nombre de composantes. Un objet de type Vecteur2D possède deux composantes, tandis qu’un objet de type Vecteur3D en Le constructeur de la classe Vecteur est placé dans la partie protected. Il pourra ainsi être utilisé par les classes qui hériteront de Vecteur, mais il ne pourra pas être appelé directement depuis la Nous disposons maintenant d’une classe générale représentant une propriété commune à plusieurs classes. Il reste à établir une relation entre cette classe Vecteur et les classes Vecteur2D et Cette relation est précisément ce que permet l’héritage. Pourquoi ne pas utiliser l’héritage ? Nous avons constaté que les classes Vecteur2D et Vecteur3D possèdent de nombreux points communs. Il pourrait donc sembler naturel de chercher à factoriser ces éléments dans une classe plus
Accueil
2026 – C++ Partie II générale appelée Vecteur. Cette idée est séduisante, mais elle rencontre rapidement une difficulté fondamentale : les opérations communes aux deux classes ne sont pas nécessairement les mêmes opérations du point de vue de leur implémentation. Considérons par exemple l’addition. Dans le plan, R2 × R2 −→ R2 , alors que dans l’espace, R3 × R3 −→ R3 . Les deux opérations portent le même nom et obéissent au même principe mathématique, mais elles agissent sur des objets différents et produisent des résultats de types différents. Il en va de même pour le produit scalaire : La norme ou la construction d’un vecteur unitaire présentent une difficulté semblable : leur définition générale est commune, mais leur calcul dépend du nombre de composantes et de la représentation Nous pourrions certainement construire une classe Vecteur capable de représenter des vecteurs de dimension quelconque. Cela nécessiterait cependant d’introduire dès maintenant des mécanismes supplémentaires pour gérer le nombre et le stockage des composantes. Nous nous éloignerions alors de notre objectif actuel, qui est de comprendre le principe de l’héritage. Il existe surtout une raison plus importante : l’héritage doit traduire une relation naturelle entre les objets représentés. Or un Vecteur3D n’est pas une spécialisation de Vecteur2D, pas plus qu’un Vecteur2D n’est une spécialisation de Vecteur3D. Les ressemblances entre deux classes ne suffisent donc pas à justifier une relation d’héritage. • Deux classes peuvent posséder des méthodes semblables sans qu’une relation d’héritage • L’héritage doit traduire une véritable relation de spécialisation entre les classes. • Il ne faut pas modifier artificiellement la représentation d’un problème uniquement pour pouvoir utiliser l’héritage. • Lorsqu’une hiérarchie de classes paraît artificielle, il est souvent préférable de chercher Les vecteurs nous ont ainsi permis de découvrir une première règle essentielle de conception en programmation orientée objet : avant de se demander comment utiliser l’héritage, il faut se demander s’il est réellement justifié.
Accueil
2026 – C++ Partie II Pour étudier son fonctionnement, nous allons donc nous tourner vers une famille d’objets mathématiques présentant une véritable relation de spécialisation : les matrices. Un exemple plus naturel : les matrices L’étude des classes Vecteur2D et Vecteur3D nous a montré qu’une ressemblance entre deux classes ne suffit pas à justifier une relation d’héritage. Pour introduire ce mécanisme, nous allons donc nous tourner vers une autre famille d’objets mathématiques : les matrices. Contrairement aux vecteurs de dimensions différentes, certaines familles de matrices possèdent naturellement une relation de spécialisation. Toute matrice carrée est une matrice. De même, toute matrice symétrique est une matrice carrée. Nous pouvons écrire : {matrices symétriques} ⊂ {matrices carrées} ⊂ {matrices}. Cette relation mathématique suggère directement une hiérarchie de classes : Dans ce cas, la relation exprimée par l’héritage possède une signification mathématique précise : • une MatriceCarree est une Matrice ; • une MatriceSymetrique est une MatriceCarree. Nous allons donc construire progressivement cette hiérarchie, en commençant par la classe la plus générale : Matrice. Une matrice quelconque est notamment caractérisée par son nombre de lignes et son nombre de colonnes. Une matrice de m lignes et n colonnes contient Nous pouvons donc commencer la définition de notre classe par les deux membres suivants : Matrice(int lignes, int colonnes);
Accueil
2026 – C++ Partie II int getNombreLignes() const; int getNombreColonnes() const; Une difficulté nouvelle apparaît : le nombre de coefficients que devra contenir un objet Matrice n’est pas nécessairement connu lors de l’écriture du programme. Nous aurons donc besoin de pouvoir réserver de la mémoire au moment de la création de Cette technique, appelée allocation dynamique de mémoire, est étudiée en détail dans la Partie III de ce cours. Stocker les coefficients Nous savons maintenant qu’il est possible de réserver dynamiquement une zone de mémoire dont la taille est déterminée pendant l’exécution du programme. Une matrice de m lignes et n colonnes possède m × n coefficients. Nous pouvons donc stocker tous ces coefficients dans un tableau dynamique de double contenant exactement La classe Matrice peut alors être complétée de la manière suivante : double* coefficients; Matrice(int lignes, int colonnes); int getNombreLignes() const; int getNombreColonnes() const; Comment stocker les coefficients Les coefficients d’une matrice forment naturellement un tableau à deux dimensions. En mémoire, nous pouvons cependant les stocker dans un unique tableau linéaire de double. Nous ajoutons donc à la classe un pointeur vers la zone mémoire contenant les coefficients :
Accueil
2026 – C++ Partie II double* coefficients; Matrice(int lignes, int colonnes); int getNombreLignes() const; int getNombreColonnes() const; double getCoefficient(int ligne, int colonne) const; void setCoefficient(int ligne, Le membre coefficients ne contient pas les coefficients eux-mêmes. Il contient l’adresse du premier élément d’une zone de mémoire réservée dynamiquement. Pour une matrice possédant m lignes et n colonnes, cette zone devra contenir exactement valeurs de type double. Construire et détruire une matrice Le constructeur reçoit le nombre de lignes et de colonnes. Il initialise ces deux membres puis réserve la mémoire nécessaire aux coefficients. Matrice::Matrice(int lignes, int colonnes) : nombreLignes(lignes), nombreColonnes(colonnes), new double[lignes * colonnes]{} new double[lignes * colonnes]{} double* coefficients; coefficients = new double[lignes * colonnes]{}; L’accolade vide initialise les coefficients à zéro.
Accueil
2026 – C++ Partie II La mémoire réservée dynamiquement doit être libérée lorsque l’objet Matrice disparaît. Cette opération est effectuée dans le destructeur : delete[] coefficients; // coefficient = nullptr; // inutile ici Après delete ou delete[], mettre le pointeur à nullptr n’est utile si le pointeur continue d’exister et peut encore être utilisé. Dans un destructeur, c’est généralement inutile puisque le pointeur membre disparaît immédiatement avec l’objet. Le constructeur réserve donc la ressource nécessaire à l’objet, tandis que le destructeur la restitue lorsqu’elle n’est plus utilisée. Cette première version de la classe est volontairement limitée. Un objet possédant une ressource dynamique ne peut pas être copié correctement avec les mécanismes de copie par défaut du C++. Nous éviterons donc pour l’instant de copier les La copie profonde, le constructeur de copie et l’opérateur d’affectation seront étudiés dans la Repérer un coefficient dans la mémoire Considérons une matrice possédant m lignes et n colonnes. Ses coefficients peuvent être rangés ligne après ligne dans le tableau dynamique : En mémoire, ils sont disposés dans l’ordre : a 00 , a 01 , . . . , a 0,n−1 , a 10 , a 11 , . . . , a m−1,n−1 . Le coefficient situé à la ligne i et à la colonne j possède alors l’indice Nous pouvons donc écrire : double Matrice::getCoefficient( return coefficients[
Accueil
2026 – C++ Partie II ligne * nombreColonnes + colonne void Matrice::setCoefficient( ligne * nombreColonnes + colonne Les dimensions de la matrice sont obtenues de manière classique : int Matrice::getNombreLignes() const return nombreLignes; int Matrice::getNombreColonnes() const return nombreColonnes; A.setCoefficient(0, 0, 2.0); A.setCoefficient(0, 1, 5.0); A.setCoefficient(1, 2, 7.0); << A.getCoefficient(1, 2) Accéder aux coefficients Les coefficients de la matrice sont stockés dans un tableau dynamique unidimensionnel. Pour accéder à un coefficient donné, nous devons donc établir une correspondance entre sa position dans la matrice et sa position dans le tableau. Considérons par exemple une matrice possédant 2 lignes et 3 colonnes : Les coefficients sont stockés ligne après ligne dans le tableau dynamique : coefficient a00 a01 a02 a10 a11 a12
Accueil
2026 – C++ Partie II Pour une matrice possédant nombreColonnes colonnes, le coefficient situé à la ligne ligne et à la colonne colonne se trouve donc à l’indice ligne * nombreColonnes + colonne. Par exemple, dans notre matrice à trois colonnes, le coefficient a12 se trouve à l’indice Nous pouvons utiliser cette relation pour construire les méthodes permettant de lire et de modifier La méthode getCoefficient() reçoit le numéro de la ligne et de la colonne du coefficient recherché : double Matrice::getCoefficient( return coefficients[ ligne * nombreColonnes + colonne Modifier un coefficient La méthode setCoefficient() reçoit la position du coefficient ainsi que sa nouvelle valeur : void Matrice::setCoefficient( ligne * nombreColonnes + colonne Les méthodes getCoefficient() et setCoefficient() illustrent un avantage important de l’encapsulation. L’utilisateur de la classe manipule les coefficients à l’aide de leur ligne et de leur colonne. Il n’a pas besoin de savoir que ceux-ci sont en réalité stockés dans un tableau unidimensionnel. La classe Matrice se charge elle-même de convertir les deux indices en une position dans le
Accueil
2026 – C++ Partie II Les méthodes getCoefficient() et setCoefficient() supposent que les indices fournis correspondent à une position existante dans la matrice. Pour une matrice possédant m lignes et n colonnes, ils doivent vérifier Nous ne mettons volontairement pas en place de contrôle supplémentaire dans cette première version de la classe. Multiplication matricielle Notre classe doit encore être pourvue de la multiplication matricielle. Voici son implémentation dans Matrice Matrice::multiplier( const Matrice& autre) const autre.nombreColonnes for (int i = 0; i < nombreLignes; ++i) for (int j = 0; j < autre.nombreColonnes; ++j) for (int k = 0; k < nombreColonnes; ++k) getCoefficient(i, k) autre.getCoefficient(k, j); resultat.setCoefficient(i, j, somme); La juste correspondance entre le nombre de colonnes de la première matrice et le nombre de ligne de la matrice en argument n’est pas vérifiée. Constructeur de copie Si on veut par exemple effecture l’opération
Accueil
2026 – C++ Partie II il nous faut un constructeur par copie profonde afin que si A est détruite, B ne le soit pas également. On ne veut pas que la matrice A pointe vers les coefficients de la matrice B, on désire une construction en profondeur, une réplication exacte qui crée de nouveaux emplacements de mémoire pour la copie Ce constructeur est : Matrice::Matrice(const Matrice& autre) autre.nombreColonnes for (int i = 0; i < nombreLignes; ++i) for (int j = 0; j < nombreColonnes; ++j) autre.getCoefficient(i, j) Le code crée immédiatement une matrice possédant son propre tableau dynamique aux bonnes dimensions. Il ne reste ensuite qu’à recopier les coefficients. Affectation par copie Ici, contrairement au constructeur de copie, l’objet existe déjà. Il faut donc d’abord libérer son ancienne allocation, adapter ses dimensions, effectuer une nouvelle allocation puis recopier les coefficients. Si on a par exemple déjà les deux matrices A et B et qu’on veut faire Les deux matrices existant déjà, il faut s’assurer de détruire A pour lui affecter B et également prévoir le cas A = A par exemple. est ici indispensable à la correction de notre algorithme, pas simplement une optimisation. Si A = A, il dit simplement : source et destination sont le même objet ; il n’y a absolument rien à faire. C’est d’ailleurs maintenant que l’intérêt de this devient particulièrement évident : sans comparer this et &autre, notre opérateur d’affectation profonde détruirait lui-même sa source lors d’une Le code de cette surcharge d’affectation est : Matrice Matrice::multiplier( const Matrice& autre) const
Accueil
2026 – C++ Partie II autre.nombreColonnes for (int i = 0; i < nombreLignes; ++i) for (int j = 0; j < autre.nombreColonnes; ++j) for (int k = 0; k < nombreColonnes; ++k) getCoefficient(i, k) autre.getCoefficient(k, j); resultat.setCoefficient(i, j, somme); avec la précondition nombreColonnes = autre.nombreLignes 1 Dans le fichier d’en-tête, les 3 méthodes que l’on vient d’ajouter s’écrivent : Matrice(int lignes, int colonnes); const Matrice& autre); Matrice(const Matrice& autre); const Matrice& autre) const; Matrice multiplier(const Matrice& autre) const; Classe Matrice de base La classe que nous venons de la construire, va maintenant devenir notre classe de base qui nous permet d’attaquer véritablement l’héritage. Le code de cette classe de base est : Code 3.4 – Fichier en-tête – Matrice.h (version 1.0) 1. On traitera l’interception d’exceptions plus loin dans le cour, pour l’instant on va au plus important.
Accueil
2026 – C++ Partie II Created by Michel Semon on 11.08.2026. double∗ coefficients; Matrice(int lignes, int colonnes); Matrice(const Matrice& autre); Matrice& operator=(const Matrice& autre); int getNombreLignes() const; int getNombreColonnes() const; double getCoefficient(int ligne, int colonne) const; void setCoefficient(int ligne, Matrice multiplier(const Matrice& autre) const; void afficherMatrice() const; Code 3.5 – Fichier en-tête – Matrice.cpp (version 1.0) Created by Michel Semon on 11.08.2026.
Accueil
2026 – C++ Partie II #include « Matrice.h » Matrice::Matrice(int lignes, int colonnes) : nombreLignes(lignes), nombreColonnes(colonnes), coefficients(new double[lignes ∗ colonnes]{}) (const Matrice& autre) : Matrice(autre.nombreLignes,autre. for (int i=0; i< nombreLignes;i++) for (int j=0; j<nombreColonnes; j++) { autre.getCoefficient(i, j) Matrice& Matrice::operator=(const Matrice& autre) delete[] coefficients; nombreLignes = autre.nombreLignes; nombreColonnes = autre.nombreColonnes; coefficients = new double[nombreLignes ∗ nombreColonnes]; for (int i=0; i<nombreLignes; ++i) { for (int j=0; j<nombreColonnes; j++) { this−>setCoefficient(i, j, autre.getCoefficient(i, j));
Accueil
2026 – C++ Partie II delete[] coefficients; int Matrice::getNombreLignes() const return nombreLignes; int Matrice::getNombreColonnes() const return nombreColonnes; double Matrice::getCoefficient(int ligne, return coefficients[ ligne ∗ nombreColonnes + colonne void Matrice::setCoefficient(int ligne, ligne ∗ nombreColonnes + colonne // On suppose dans un premier temps que les dimensions sont adequate pour l’ operation de multiplication Matrice Matrice::multiplier(const Matrice& autre) const autre.nombreColonnes for (int i = 0; i < nombreLignes; i++) for (int j = 0; j < autre.nombreColonnes; j++) for (int k = 0; k < nombreColonnes; k++) getCoefficient(i, k)
Accueil
2026 – C++ Partie II autre.getCoefficient(k, j); resultat.setCoefficient(i, j, somme); void Matrice::afficherMatrice() const for (int i=0; i<nombreLignes; i++) for (int j=0; j<nombreColonnes; j++) std::cout << getCoefficient(i, j) <<  » « ; std::cout << std::endl; Le fichier compressé contenant les codes de la classe Matrice v1.1 peut être téléchargé ici : Matrice La classe Matrice que nous venons de construire représente une matrice quelconque. Jusqu’à présent, nous l’avons étudiée comme une classe indépendante. Nous allons maintenant changer de point de vue. Certaines matrices possèdent des propriétés supplémentaires tout en restant des matrices. Une matrice carrée, par exemple, est une matrice dont le nombre de lignes est égal au nombre de colonnes. {matrices carrées} ⊂ {matrices}. Cette relation peut être traduite directement dans notre programme par une relation d’héritage. La classe Matrice va jouer le rôle de classe de base. Nous allons construire à partir d’elle une nouvelle classe MatriceCarree, appelée classe dérivée. L’objectif n’est pas de recopier dans MatriceCarree ce que nous avons déjà programmé dans Matrice. Au contraire, nous allons chercher à réutiliser les caractéristiques et les comportements déjà définis dans la classe de base. Une première classe dérivée : MatriceCarree Une matrice carrée est une matrice possédant le même nombre de lignes et de colonnes. Une matrice carrée d’ordre n possède donc n lignes et n colonnes. Puisqu’une matrice carrée est un cas particulier de matrice, nous pouvons représenter cette relation directement en C++ en déclarant MatriceCarree comme une classe dérivée de Matrice.
Accueil
2026 – C++ Partie II Une première déclaration peut être extrêmement simple : #ifndef MATRICECARREE_H #define MATRICECARREE_H #include « Matrice.h » class MatriceCarree : public Matrice indique que la classe MatriceCarree dérive de la classe Matrice. • Matrice est la classe de base ; • MatriceCarree est la classe dérivée. La classe dérivée peut ainsi réutiliser une partie de ce qui a déjà été défini dans la classe de base. La classe MatriceCarree ne contient encore aucune déclaration entre ses accolades. Cela signifie-t-il qu’elle ne possède aucune fonctionnalité ? Non. La classe MatriceCarree dérive de Matrice. Elle dispose donc déjà des membres accessibles hérités de cette classe de base. Par exemple, une matrice carrée pourra utiliser les méthodes publiques déjà définies dans Matrice, Il n’est donc pas nécessaire de réécrire ces méthodes dans MatriceCarree.
Accueil
2026 – C++ Partie II L’héritage ne signifie pas que la classe dérivée peut accéder librement à tous les membres de Les membres déclarés private dans Matrice restent inaccessibles directement depuis le code de MatriceCarree. Nous étudierons précisément les niveaux d’accès dans la suite du chapitre. Une classe qui n’est pas encore utilisable Notre classe MatriceCarree possède maintenant une relation d’héritage avec Matrice, mais nous rencontrons immédiatement un problème lorsque nous essayons de créer un objet : La classe de base Matrice ne possède pas de constructeur sans argument. Son constructeur exige Matrice(int lignes, int colonnes); Or une matrice carrée d’ordre n ne nécessite qu’une seule information : son ordre. Nous souhaiterions donc pouvoir écrire : et obtenir automatiquement une matrice possédant Le constructeur d’une classe dérivée Nous souhaitons pouvoir créer une matrice carrée en indiquant uniquement son ordre. Par exemple, doit créer une matrice possédant quatre lignes et quatre colonnes. Nous pouvons donc commencer par déclarer un constructeur dans la classe MatriceCarree : Code 3.6 – MatriceCarree.h #ifndef MATRICECARREE_H #define MATRICECARREE_H #include « Matrice.h » class MatriceCarree : public Matrice
Accueil
2026 – C++ Partie II MatriceCarree(int ordre); Cependant, un objet de type MatriceCarree contient également toute la partie provenant de sa classe de base Matrice. Cette partie doit elle aussi être construite. Or le constructeur de Matrice attend deux arguments : Matrice(int lignes, int colonnes); Pour une matrice carrée d’ordre ordre, ces deux arguments sont identiques. Le constructeur de la classe dérivée peut donc appeler explicitement le constructeur de sa classe de base : Code 3.7 – MatriceCarree.cpp #include « MatriceCarree.h » MatriceCarree::MatriceCarree(int ordre) : Matrice(ordre, ordre) : Matrice(ordre, ordre) demande au constructeur de MatriceCarree de construire sa partie Matrice en appelant le constructeur Matrice(ordre, ordre); Ainsi, l’instruction provoque notamment l’appel du constructeur de Matrice avec La classe de base effectue alors exactement le travail qu’elle effectuait déjà pour une matrice quelconque : elle mémorise les dimensions et réserve dynamiquement la mémoire nécessaire aux coefficients. La classe de base est construite en premier Lorsqu’un objet d’une classe dérivée est créé, sa partie correspondant à la classe de base doit être construite avant la partie propre à la classe dérivée. Pour l’ordre est donc :
Accueil
2026 – C++ Partie II 1. appel du constructeur de MatriceCarree ; 2. construction de la partie Matrice par Matrice(4, 4) ; 3. exécution du corps du constructeur de MatriceCarree. Dans notre exemple, le corps du constructeur de MatriceCarree est vide : Tout le travail nécessaire à la création de la matrice est déjà effectué par le constructeur de la classe Utiliser les méthodes héritées Nous pouvons maintenant créer une matrice carrée et utiliser directement certaines méthodes publiques définies dans Matrice : A.setCoefficient(0, 0, 1.0); A.setCoefficient(1, 1, 2.0); A.setCoefficient(2, 2, 3.0); A.afficherMatrice(); La classe MatriceCarree ne contient pourtant aucune définition de setCoefficient() ni de Ces méthodes proviennent de la classe de base Matrice. • Un objet d’une classe dérivée contient une partie correspondant à sa classe de base. • Lors de la construction d’un objet dérivé, la classe de base est construite avant l’exécution du corps du constructeur de la classe dérivée. • Le constructeur de la classe de base peut être appelé dans la liste d’initialisation du constructeur de la classe dérivée. MatriceCarree::MatriceCarree(int ordre) : Matrice(ordre, ordre) le constructeur de Matrice crée une matrice possédant ordre lignes et ordre colonnes. • Une classe dérivée peut utiliser les méthodes publiques héritées de sa classe de base sans avoir à les réécrire.
Accueil
2026 – C++ Partie II private et protected dans l’héritage La classe Matrice contient actuellement les membres suivants : double* coefficients; Ces trois membres sont déclarés private. Nous avons déjà vu que cela interdit au code extérieur à la classe d’y accéder directement. L’apparition d’une classe dérivée pose maintenant une nouvelle question : une méthode de MatriceCarree peut-elle accéder directement aux membres privés de Matrice ? Tentative d’accès depuis la classe dérivée Ajoutons temporairement à MatriceCarree une méthode permettant d’obtenir son ordre : iint MatriceCarree::getOrdre() const return getNombreLignes(); Cette méthode ne compile pas. Bien que MatriceCarree dérive de Matrice, nombreLignes reste un membre privé de Matrice. Une classe dérivée ne possède donc pas un accès direct aux membres private de sa classe de base. Par contre, si l’on utilise la méthode public getNombreLignes cela fonctionne, notre méthode doit être par conséquent int MatriceCarree::getOrdre() const return nombreLignes; Le niveau d’accès protected Le C++ fournit un troisième niveau d’accès particulièrement utile dans le contexte de l’héritage : Un membre déclaré protected reste inaccessible au code extérieur à la classe, mais il devient directement accessible aux classes dérivées. Nous pourrions par exemple écrire :
Accueil
2026 – C++ Partie II double* coefficients; Et je comparerais alors clairement les trois niveaux : | Accès au membre | ‘public‘ | ‘protected‘ | ‘private‘ | |————————| :——– 😐 :———— 😐 :——— 😐 | Classe elle-même | oui | | Classe dérivée | oui | | Code extérieur | oui | Mais faut-il réellement transformer nos membres en ‘protected‘ ? Il n’y a aucune raison de donner accès à nombreLignes et nombreColonne à la classe MatriceCarree car elle y a accès par les getters. Il faut par contre laisser double∗ coefficients; en statut privé afin que MatriceCarree ne puisse pas faire delete[] coefficients et compromettre toute la classe Matrice. • Un membre private n’est directement accessible que depuis la classe qui le déclare. • Une classe dérivée ne peut pas accéder directement aux membres private de sa classe • Un membre protected est accessible depuis la classe qui le déclare ainsi que depuis ses classes dérivées, mais reste inaccessible au code extérieur. • Un membre public est également accessible depuis le code extérieur. • L’héritage ne supprime donc pas l’encapsulation de la classe de base. • Il n’est pas nécessaire de déclarer un membre protected simplement parce qu’une classe doit servir de classe de base. Lorsque l’interface publique existante suffit, le membre peut Un cas où protected peut être utile Dans notre classe Matrice, les membres privés peuvent rester private car les classes dérivées disposent déjà des méthodes publiques nécessaires pour les utiliser.
Accueil
2026 – C++ Partie II Il existe cependant des situations dans lesquelles une classe de base est conçue pour permettre à ses classes dérivées d’accéder directement à une partie de son état interne. Considérons une classe très simple représentant un compteur : Compteur(int valeurInitiale); int getValeur() const; Le membre valeur n’est pas public. Le code utilisant un objet Compteur ne peut donc pas le modifier directement. Nous pouvons cependant créer une classe dérivée représentant un compteur possédant une valeur class CompteurLimite : public Compteur void incrementerSiPossible(); La classe CompteurLimite doit pouvoir consulter et modifier directement la valeur du compteur tout en respectant la limite imposée. Puisque valeur est déclarée protected dans la classe de base, une méthode de la classe dérivée peut y accéder directement : void CompteurLimite::incrementerSiPossible() if (valeur < maximum)
Accueil
2026 – C++ Partie II Une classe dérivée ne sert pas seulement à récupérer ce que possède sa classe de base ; elle peut ajouter des opérations qui n’ont de sens que pour la spécialisation qu’elle représente. C’est ce que l’on va étudier maintenant. Enrichir la classe dérivée La classe MatriceCarree possède déjà les fonctionnalités qu’elle peut utiliser à travers la classe Matrice. Elle peut notamment stocker et modifier ses coefficients, les afficher ou participer à une multiplication matricielle. Mais une classe dérivée peut également posséder des fonctionnalités qui lui sont propres. Considérons le déterminant, celui-ci n’est défini que pour les matrices carrées. Il ne serait donc pas cohérent d’ajouter une méthode double determinant() const; à la classe générale Matrice, puisqu’un objet de cette classe peut par exemple représenter une Cette méthode trouve en revanche naturellement sa place dans MatriceCarree : class MatriceCarree : public Matrice MatriceCarree(int ordre); double determinant() const; Nous obtenons ainsi une classe qui conserve les fonctionnalités générales d’une matrice tout en ajoutant une opération propre aux matrices carrées. La classe MatriceCarree ne contient encore aucune déclaration entre ses accolades. Cela signifie-t-il qu’elle ne possède aucune fonctionnalité ? Non. La classe MatriceCarree dérive de Matrice. Elle dispose donc déjà des membres accessibles hérités de cette classe de base. Par exemple, une matrice carrée pourra utiliser les méthodes publiques déjà définies dans Matrice,
Accueil
2026 – C++ Partie II Il n’est donc pas nécessaire de réécrire ces méthodes dans MatriceCarree. L’héritage ne signifie pas que la classe dérivée peut accéder librement à tous les membres de Les membres déclarés private dans Matrice restent inaccessibles directement depuis le code de MatriceCarree. Nous étudierons précisément les niveaux d’accès dans la suite du chapitre. Calcul du déterminant par développement de cofacteurs Pour généraliser la méthode aux matrices carrées d’ordre supérieur à deux, nous allons utiliser le développement du déterminant par cofacteurs. Considérons une matrice carrée A d’ordre n . En développant son déterminant suivant la première ligne, nous obtenons (−1) j a 0 j det M 0 j , où M0 j désigne la matrice obtenue en supprimant de A la ligne 0 et la colonne j . La matrice M0 j est elle-même une matrice carrée, d’ordre n − 1. Son déterminant peut donc être calculé exactement de la même manière. Nous retrouvons ainsi une structure déjà étudiée dans ce cours : la récursivité. Construire une matrice mineure Pour calculer le déterminant par développement de cofacteurs, nous devons pouvoir construire une matrice obtenue en supprimant une ligne et une colonne de la matrice originale. Considérons par exemple Si nous supprimons la ligne 0 et la colonne 1, nous obtenons la matrice La matrice obtenue possède un ordre inférieur d’une unité à celui de la matrice initiale. Nous allons donc ajouter à la classe MatriceCarree une méthode privée : MatriceCarree mineur( int colonneSupprimee
Accueil
2026 – C++ Partie II Cette méthode n’est pas destinée à être utilisée directement par le programme principal. Elle constitue un outil interne nécessaire au calcul du déterminant. Pour construire le mineur, il faut parcourir tous les coefficients de la matrice originale en ignorant la ligne et la colonne à supprimer. Comme les indices de la matrice originale ne correspondent plus directement à ceux de la matrice mineure, nous utiliserons deux couples d’indices : • i et j pour parcourir la matrice originale ; • ligneMineur et colonneMineur pour remplir la nouvelle matrice. La méthode peut être écrite de la manière suivante : MatriceCarree MatriceCarree::mineur( int colonneSupprimee int ordre = getNombreLignes(); MatriceCarree resultat(ordre – 1); int ligneMineur = 0; for (int i = 0; i < ordre; ++i) if (i == ligneSupprimee) int colonneMineur = 0; for (int j = 0; j < ordre; ++j) if (j == colonneSupprimee) resultat.setCoefficient(

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Matrice v.1.0
Classe Matrice
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Matrice v.2.0
Classes Matrice, Matrice Carree, Matrice Symetrique
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Chapitre 4 - Le polymorphisme

Chapitre 4 – Le polymorphisme

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
4.1 Un même service, plusieurs comportements . . . . . . . . . . . . . . . . . . . . . . . . 4.2 Une fonction capable de recevoir n’importe quelle forme . . . . . . . . . . . . . . . . 4.2.3 Choisir la méthode d’après l’objet réel . . . . . . . . . . . . . . . . . . . . . . 4.3.1 override permet au compilateur de vérifier . . . . . . . . . . . . . . . . . . . 4.3.2 virtual et override ont des rôles différents . . . . . . . . . . . . . . . . . . 4.4 Liaison statique et liaison dynamique . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.5 Pointeurs et références polymorphes . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.5.3 Référence ou pointeur ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.6 Classes abstraites et fonctions virtuelles pures . . . . . . . . . . . . . . . . . . . . . . 4.6.1 Une méthode qui n’a pas de définition générale . . . . . . . . . . . . . . . . . 4.6.2 Une fonction virtuelle pure . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.6.4 Les classes dérivées fournissent l’implémentation . . . . . . . . . . . . . . . . 4.6.5 Une classe abstraite peut toujours être utilisée par référence . . . . . . . . . 4.7.1 Quel destructeur doit être appelé ? . . . . . . . . . . . . . . . . . . . . . . . . 4.7.2 Rendre le destructeur virtuel . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.7.3 Un destructeur virtuel par défaut . . . . . . . . . . . . . . . . . . . . . . . . . 4.8 Une collection de formes différentes . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.8.1 Un tableau de pointeurs vers la classe de base . . . . . . . . . . . . . . . . . . 4.8.2 Un même appel pour des objets différents . . . . . . . . . . . . . . . . . . . . 4.8.3 Le choix est confié à l’objet . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.8.4 Ajouter de nouvelles classes . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.8.5 Les pointeurs ne possèdent pas les objets . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie II
Accueil
Un même service, plusieurs comportements Dans le chapitre précédent, nous avons vu que l’héritage permet de construire des classes spécialisées à partir d’une classe plus générale. Nous allons maintenant nous intéresser à une autre situation. Considérons différentes figures géométriques, par exemple un carré et un disque. Ces deux objets sont de nature différente, mais ils possèdent une caractéristique commune : il est possible de calculer Pour un carré de côté c , alors que pour un disque de rayon r , L’opération demandée est donc la même : mais le calcul nécessaire pour obtenir le résultat dépend de la nature de l’objet. Nous pouvons représenter les différentes figures à l’aide d’une classe générale appelée Forme. Nous construirons dans un premier temps deux classes dérivées : Un carré est une forme et un disque est également une forme. Les deux classes dérivées posséderont une méthode portant le même nom : double aire() const; mais chacune devra effectuer un calcul différent. Commençons par déclarer la classe de base : 2026 – C++ Partie II double aire() const; Cette classe ne contient pour l’instant aucune donnée. Elle possède simplement une méthode aire() commune aux formes que nous allons étudier. Nous devons cependant donner provisoirement une définition à cette méthode : double Forme::aire() const La valeur retournée n’a ici aucune signification géométrique. Une forme générale ne possède en effet ni côté ni rayon permettant de calculer son aire. Cette définition nous servira uniquement à observer le comportement du programme avant d’introduire les mécanismes propres au polymorphisme. Les classes Carre et Disque définissent chacune leur propre méthode aire(). double Carre::aire() const double Disque::aire() const return PI * rayon * rayon; Nous avons donc trois méthodes possédant la même signature : double aire() const; mais définies dans trois classes différentes. Considérons maintenant : std::cout << C.aire() << std::endl; std::cout << D.aire() << std::endl;
Accueil
2026 – C++ Partie II Dans le premier appel, l’objet C est connu comme un Carre. La méthode utilisée est donc Dans le second, l’objet D est connu comme un Disque. La méthode utilisée est Tout fonctionne donc exactement comme prévu. Quoique ! Une question se pose cependant. Puisque Carre et Disque sont tous les deux des Forme, pouvons-nous écrire une seule fonction capable de recevoir n’importe quelle forme et de calculer automatiquement l’aire correspondant à l’objet réellement reçu ? Une fonction capable de recevoir n’importe quelle forme Nous souhaitons maintenant écrire une fonction unique capable de recevoir différents objets dérivés Pour éviter toute copie de l’objet, nous utilisons un passage par référence constante : void afficherAire(const Forme& forme) std::cout << forme.aire() Cette fonction peut recevoir aussi bien un objet de type Carre qu’un objet de type Disque, puisque ces deux classes dérivent de Forme. aucune copie de C n’est effectuée.
Accueil
2026 – C++ Partie II désigne directement l’objet C, mais celui-ci est vu à travers le type de la classe de base Forme. Il en va de même pour l’objet D. Cependant, avec la version actuelle de notre programme, la méthode est choisie à partir du type de la référence forme, c’est-à-dire Forme. La méthode appelée est donc : Comme cette méthode retourne provisoirement 0.0, le programme affiche : Les objets transmis à la fonction sont pourtant réellement un Carre et un Disque. Pourquoi le programme n’appelle-t-il pas automatiquement Carre::aire() et Disque:: Dans notre version actuelle, le choix de la méthode est effectué à partir du type statique de la référence utilisée pour l’appel. Le paramètre forme étant de type const Forme&, la méthode Forme::aire() est sélectionnée. Nous allons maintenant modifier ce comportement afin que la méthode appelée dépende du type réel Reprenons la fonction générale construite précédemment : void afficherAire(const Forme& forme) std::cout << forme.aire() Le passage s’effectue par référence constante. L’objet transmis à la fonction n’est donc pas copié.
Accueil
2026 – C++ Partie II l’objet réel reste un Carre, mais il est manipulé dans la fonction à travers une référence de type l’objet réel reste un Disque, mais il est également manipulé à travers une référence de type const Dans notre première version, la classe Forme contient : double aire() const; Lorsque l’instruction est exécutée, le choix de la méthode est effectué à partir du type de la référence utilisée pour l’appel. Cette référence étant de type Forme, la méthode appelée est donc même lorsque l’objet réel est un Carre ou un Disque. Dans notre exemple, cette méthode retourne provisoirement zéro. Les appels Choisir la méthode d’après l’objet réel Nous souhaitons au contraire que le programme tienne compte du type réel de l’objet désigné par la référence. Pour cela, la méthode est déclarée virtual dans la classe de base : virtual double aire() const; Le mot-clé virtual indique que le choix de la méthode doit pouvoir être effectué en fonction du type réel de l’objet lorsque celui-ci est manipulé à travers une référence ou un pointeur vers une
Accueil
2026 – C++ Partie II Nous ne modifions rien dans la fonction : void afficherAire(const Forme& forme) std::cout << forme.aire() utilisent maintenant respectivement car les objets réellement désignés par la référence sont respectivement un Carre et un Disque. Pour un carré de côté 3 et un disque de rayon 2, le programme affiche : • Sans virtual, le choix de la méthode est effectué d’après le type statique de la référence ou du pointeur utilisé pour l’appel. • Avec virtual, le programme peut choisir la méthode d’après le type réel de l’objet désigné par cette référence ou ce pointeur. • Le passage par référence est essentiel ici : aucune copie de l’objet n’est effectuée et son type réel est conservé. Le mot-clé override La classe de base Forme déclare maintenant la méthode virtuelle : virtual double aire() const; Les classes dérivées possèdent leur propre version de cette méthode. Dans Carre, nous pouvons double aire() const override; double aire() const override;
Accueil
2026 – C++ Partie II Le mot-clé override indique au compilateur que cette méthode doit redéfinir une méthode virtuelle héritée de la classe de base. override permet au compilateur de vérifier Considérons la déclaration correcte : double aire() const override; Elle correspond bien à la méthode de la classe de base : virtual double aire() const; Supposons maintenant que nous oubliions par erreur le mot-clé const : double aire() override; La signature n’est plus identique à celle de la méthode virtuelle définie dans Forme. Grâce à override, le compilateur détecte immédiatement le problème et signale que cette méthode ne redéfinit pas la méthode virtuelle attendue. Sans override, cette erreur serait beaucoup moins évidente : le programmeur pourrait croire qu’il a redéfini la méthode virtuelle alors qu’il a en réalité déclaré une méthode différente. virtual et override ont des rôles différents Les deux mots-clés ne sont donc pas synonymes. Dans la classe de base : virtual double aire() const; virtual demande un choix dynamique de la méthode. Dans une classe dérivée : double aire() const override; override demande au compilateur de vérifier que cette méthode redéfinit effectivement une méthode virtuelle héritée. • virtual est placé dans la classe de base pour permettre le choix dynamique de la • override est utilisé dans une classe dérivée pour indiquer qu’une méthode redéfinit une méthode virtuelle héritée. • override permet au compilateur de vérifier que la signature de la méthode correspond bien à celle de la méthode virtuelle. • Une différence telle que l’oubli de const suffit à produire une signature différente.
Accueil
2026 – C++ Partie II Liaison statique et liaison dynamique Nous venons d’observer deux comportements différents selon que la méthode aire() est déclarée ou non virtual. Ces deux mécanismes portent des noms précis : • la liaison statique ; • la liaison dynamique. Le terme liaison désigne ici le mécanisme qui associe un appel de fonction à la fonction qui sera effectivement exécutée. Considérons tout d’abord une méthode non virtuelle : double aire() const; void afficherAire(const Forme& forme) std::cout << forme.aire() Le paramètre forme est déclaré comme une référence vers un objet de type Forme : Lorsque aire() n’est pas virtuelle, le choix de la méthode est effectué à partir de ce type. est donc associé à Cette association peut être déterminée lors de la compilation. On parle alors de liaison statique. le type déclaré de forme est
Accueil
2026 – C++ Partie II Ce type est appelé type statique. Il est connu par le compilateur avant l’exécution du programme. Ainsi, sans méthode virtuelle, les appels conduisent tous les deux à l’utilisation de même si les objets transmis sont réellement un Carre et un Disque. La liaison dynamique Déclarons maintenant la méthode virtuelle : virtual double aire() const; Les classes dérivées redéfinissent cette méthode : double aire() const override; La fonction afficherAire() reste exactement la même : void afficherAire(const Forme& forme) std::cout << forme.aire() Cette fois, le choix de la méthode ne dépend plus uniquement du type statique de la référence. Le programme tient compte du type réel de l’objet désigné par cette référence. l’objet réel est un Carre. La méthode appelée est donc l’objet réel est un Disque. La méthode appelée est donc
Accueil
2026 – C++ Partie II Le choix dépend donc de l’objet réellement présent lors de l’exécution du programme. On parle de la référence forme possède toujours le type statique mais l’objet réellement désigné est un Carre. Le type réel de cet objet est appelé ici son type dynamique. De même, lors de le type statique de la référence est toujours Forme, alors que le type dynamique de l’objet est Disque. Nous pouvons résumer les deux mécanismes de la manière suivante : méthode non virtuelle choix déterminable à la compilation choix selon l’objet à l’exécution • La liaison associe un appel de méthode à la méthode qui sera effectivement exécutée. • Avec une méthode non virtuelle, le choix dépend du type statique de l’expression utilisée pour effectuer l’appel. On parle de liaison statique. • Avec une méthode virtuelle appelée à travers une référence ou un pointeur de base, le choix tient compte du type réel de l’objet. On parle de liaison dynamique. • Une référence de type Forme& peut donc désigner un objet dont le type réel est Carre, Disque ou une autre classe dérivée de Forme. Pointeurs et références polymorphes Jusqu’à présent, nous avons utilisé une référence vers la classe de base : void afficherAire(const Forme& forme) std::cout << forme.aire() Cette référence peut désigner un objet de type Carre, Disque ou toute autre classe dérivée de Forme. Le polymorphisme fonctionne également avec un pointeur vers la classe de base.
Accueil
2026 – C++ Partie II Avec une référence Les références F1 et F2 possèdent toutes deux le type statique Forme&. Cependant, elles désignent des objets dont les types dynamiques sont respectivement Carre et Disque. Si la méthode aire() est virtuelle, std::cout << F1.aire() << std::endl; std::cout << F2.aire() << std::endl; appelle respectivement Le même mécanisme peut être utilisé avec des pointeurs : Cette fois, F1 et F2 contiennent l’adresse des objets. L’appel d’une méthode s’effectue avec l’opérateur −> : std::cout << F1->aire() << std::endl; std::cout << F2->aire() << std::endl; Comme aire() est virtuelle, le programme tient compte du type dynamique de l’objet pointé. Les méthodes appelées sont donc à nouveau Référence ou pointeur ? Les deux écritures suivantes permettent donc le polymorphisme : Forme& reference = C; Forme* pointeur = &C; Dans le premier cas, l’appel s’écrit :
Accueil
2026 – C++ Partie II Une référence doit désigner un objet lors de son initialisation et ne peut pas être réaffectée pour désigner un autre objet. Un pointeur peut en revanche recevoir successivement différentes adresses. • Le polymorphisme dynamique fonctionne avec des références ou des pointeurs vers une • Une référence utilise l’opérateur . pour appeler une méthode. • Un pointeur utilise l’opérateur −>. • Dans les deux cas, si la méthode est virtuelle, la version appelée est déterminée d’après le type dynamique de l’objet. • Une référence reste liée au même objet après son initialisation, alors qu’un pointeur peut être modifié pour désigner un autre objet. Classes abstraites et fonctions virtuelles pures Jusqu’à présent, notre classe de base Forme possède une méthode virtuelle : virtual double aire() const; Pour pouvoir effectuer nos premières expériences sur le polymorphisme, nous avions donné à cette méthode une définition provisoire : double Forme::aire() const Cette solution nous a permis d’observer le comportement du programme avant et après l’introduction du mot-clé virtual. Elle présente cependant un problème de conception. Une méthode qui n’a pas de définition générale Pour un carré, le calcul de l’aire est parfaitement défini :
Accueil
2026 – C++ Partie II Mais que devrait calculer la méthode pour une forme générale ? La classe Forme ne contient aucune information permettant d’effectuer ce calcul. Retourner arbitrairement n’est pas une solution satisfaisante. La valeur zéro représente en effet une valeur numérique parfaitement valide. Rien ne permettrait de distinguer cette valeur d’un véritable résultat. Ce que nous souhaitons exprimer est différent : Toute forme doit posséder une méthode permettant de calculer son aire, mais il n’existe pas de calcul général de cette aire dans la classe Forme. Le C++ permet d’exprimer directement cette situation à l’aide d’une fonction virtuelle pure. Une fonction virtuelle pure Une fonction virtuelle pure est déclarée en ajoutant Notre classe Forme devient donc : virtual double aire() const = 0; Attention : le symbole ne signifie absolument pas que la fonction retourne la valeur zéro. Il indique que aire() est une fonction virtuelle pure. Une classe abstraite Une classe contenant au moins une fonction virtuelle pure est une classe abstraite. Il n’est plus possible de créer directement une instance de Forme :
Accueil
2026 – C++ Partie II Cette interdiction est logique : une Forme générale ne contient pas suffisamment d’informations pour La classe Forme reste cependant parfaitement utilisable comme classe de base. Les classes dérivées fournissent l’implémentation La classe Carre fournit sa propre version de la méthode : class Carre : public Forme Carre(double coteInitial); double aire() const override; double Carre::aire() const De même, la classe Disque fournit : double aire() const override; double Disque::aire() const return PI * rayon * rayon; La classe Forme ne fournit donc plus le calcul de l’aire. Elle impose simplement une propriété commune aux classes qui représentent des formes concrètes : une forme concrète doit savoir calculer son aire. Chaque classe dérivée décide ensuite de la manière dont ce calcul doit être effectué. Une classe abstraite peut toujours être utilisée par référence Bien qu’il soit impossible de construire directement un objet de type Forme, une référence vers Forme reste parfaitement valable.
Accueil
2026 – C++ Partie II void afficherAire(const Forme& forme) std::cout << forme.aire() ne doit donc pas être modifiée. Nous pouvons toujours écrire : La référence forme désigne successivement un objet Carre puis un objet Disque. Grâce à la liaison dynamique, les méthodes sont respectivement utilisées. • Une fonction virtuelle pure est déclarée à l’aide de la syntaxe : virtual double aire() const = 0; • Le symbole = 0 ne représente pas une valeur retournée par la fonction. Il indique que la fonction est virtuelle pure. • Une classe contenant au moins une fonction virtuelle pure est une classe abstraite. • Il n’est pas possible de créer directement une instance d’une classe abstraite. • Une classe abstraite peut cependant être utilisée comme classe de base et être manipulée à travers des références ou des pointeurs. • Une classe dérivée qui doit devenir concrète fournit une implémentation des fonctions virtuelles pures héritées. Le mot-clé override permet de faire vérifier cette redéfinition
Accueil
2026 – C++ Partie II Le destructeur virtuel Le polymorphisme permet à un pointeur vers une classe de base de désigner un objet appartenant à une classe dérivée. Forme* forme = new Carre(3.0); Le type du pointeur est mais l’objet réellement construit avec new est un Carre. Grâce à la méthode virtuelle aire(), l’instruction appelle correctement Le type réel de l’objet est donc pris en compte lors de l’appel de la méthode. Une question importante apparaît cependant lorsque nous voulons détruire cet objet. Quel destructeur doit être appelé ? L’objet ayant été créé avec new, nous devons finalement le détruire avec : Mais le pointeur forme est de type Forme*, alors que l’objet réellement présent en mémoire est un Carre. Nous devons donc nous demander quel destructeur doit être exécuté. Pour détruire correctement l’objet complet, nous voulons obtenir : Carre:: Carre() −→ Forme:: Forme() La destruction doit commencer par la classe la plus spécialisée puis remonter la hiérarchie. Rendre le destructeur virtuel Lorsqu’un objet d’une classe dérivée doit pouvoir être détruit à travers un pointeur vers sa classe de base, le destructeur de la classe de base doit être déclaré virtual. Nous écrivons donc : virtual double aire() const = 0;
Accueil
2026 – C++ Partie II Un destructeur virtuel par défaut Si le destructeur de la classe de base ne doit effectuer aucune opération particulière, nous pouvons demander au compilateur de fournir son implémentation : virtual ~Forme() = default; Le destructeur reste virtuel, mais nous n’avons pas besoin d’en écrire manuellement la définition. Cette écriture est particulièrement adaptée à notre classe Forme. • Un objet d’une classe dérivée peut être manipulé à travers un pointeur vers sa classe de • Si cet objet doit être détruit avec delete à travers ce pointeur de base, le destructeur de la classe de base doit être virtuel. • Un destructeur virtuel garantit que la destruction commence par la classe correspondant au type réel de l’objet puis remonte la hiérarchie. • Si le destructeur n’a aucun travail particulier à effectuer, on peut écrire : virtual ~Forme() = default; Une collection de formes différentes Le polymorphisme devient particulièrement utile lorsque plusieurs objets appartenant à des classes différentes doivent être traités de manière uniforme. Considérons plusieurs objets : Ces objets n’ont pas tous le même type mais possèdent cependant une classe de base commune : Nous pouvons donc utiliser des pointeurs vers cette classe de base pour désigner chacun de ces objets.
Accueil
2026 – C++ Partie II Un tableau de pointeurs vers la classe de base Déclarons un tableau contenant des pointeurs vers Forme : Les quatre éléments du tableau possèdent exactement le même type : Nous pouvons cependant leur affecter les adresses d’objets appartenant à différentes classes dérivées : Nous obtenons donc schématiquement : Le type statique de chaque élément du tableau est Forme*, mais le type dynamique de l’objet pointé peut être différent. Un même appel pour des objets différents Nous pouvons maintenant parcourir le tableau : for (int i = 0; i < 4; i++) std::cout << formes[i]->aire() La même instruction est exécutée à chaque passage dans la boucle. Pourtant, la méthode réellement appelée dépend de l’objet désigné par le pointeur et nous obtenons Le programme n’a besoin d’aucun test permettant de déterminer explicitement la nature de chaque
Accueil
2026 – C++ Partie II Le choix est confié à l’objet Le programme qui parcourt la collection n’a pas besoin de savoir si l’objet rencontré est un carré ou Il demande simplement : La liaison dynamique sélectionne ensuite automatiquement la méthode correspondant au type réel C’est l’un des intérêts essentiels du polymorphisme : le programme peut manipuler différents objets à travers une interface commune sans devoir tester explicitement leur type. Ajouter de nouvelles classes Supposons que nous ajoutions maintenant une nouvelle classe Triangle dérivée de Forme. Si cette classe fournit correctement double aire() const override; un pointeur Forme* peut également désigner un objet de cette nouvelle classe. La boucle qui traite la collection n’a pas besoin de connaître Triangle et son principe ne doit pas être modifié. Elle continue simplement à exécuter : La classe de l’objet détermine elle-même la version de aire() qui sera exécutée. Les pointeurs ne possèdent pas les objets Dans cet exemple, les objets ont été créés directement : Le tableau contient uniquement leurs adresses : Aucune allocation dynamique n’a été effectuée avec new. Il ne faut donc pas utiliser delete sur ces pointeurs car les objets C1 et D1 seront détruits automatiquement lorsqu’ils atteindront la fin de leur durée de vie.
Accueil
2026 – C++ Partie II • Un pointeur vers une classe de base peut désigner un objet appartenant à une classe • Un tableau de pointeurs vers une classe de base peut donc désigner des objets appartenant à plusieurs classes dérivées différentes. • Le type statique des pointeurs reste identique, mais le type dynamique des objets pointés • Lorsqu’une méthode est virtuelle, le même appel peut provoquer des comportements différents selon le type dynamique de l’objet. • L’ajout d’une nouvelle classe dérivée ne nécessite pas de modifier le principe de la boucle qui traite les objets à travers l’interface commune. • Un pointeur contenant l’adresse d’un objet qui n’a pas été créé avec new ne doit pas être utilisé avec delete L’héritage étudié au chapitre précédent permet de construire une nouvelle classe à partir d’une classe existante. Le polymorphisme va plus loin : il permet de manipuler des objets de classes différentes à travers une même classe de base tout en conservant leur comportement propre. Dans notre exemple, les classes Carre, Disque et Triangle dérivent toutes de la classe Forme. Elles possèdent une même méthode : double aire() const; mais chacune en fournit une implémentation différente. virtual double aire() const = 0; dans la classe Forme permet à cette méthode d’être choisie dynamiquement en fonction du type réel Nous pouvons ainsi écrire : et placer dans ce tableau les adresses d’objets appartenant à différentes classes dérivées. Une seule instruction suffit alors pour calculer l’aire de chacun d’eux : Le code qui parcourt la collection n’a pas besoin de déterminer lui-même si l’objet rencontré est un carré, un disque ou un triangle. Cette responsabilité est confiée au mécanisme de liaison dynamique. Le polymorphisme permet ainsi de séparer deux problèmes :
Accueil
2026 – C++ Partie II • le programme utilisant les objets demande une opération commune ; • chaque classe spécialisée définit la manière dont cette opération doit être réalisée. Cette séparation permet notamment d’ajouter de nouvelles classes dérivées sans modifier le principe du code qui manipule les objets à travers leur classe de base. • Une méthode déclarée virtual peut être sélectionnée en fonction du type dynamique • Le mot-clé override permet de vérifier qu’une méthode redéfinit correctement une méthode virtuelle héritée. • Une fonction virtuelle pure est déclarée avec = 0. Une classe qui contient une telle fonction est abstraite. • Une classe abstraite ne peut pas être instanciée directement, mais elle peut servir de type commun pour des références et des pointeurs. • Le polymorphisme dynamique nécessite de conserver l’objet réel. Il s’utilise donc notamment à travers des références ou des pointeurs vers la classe de base. • Lorsqu’un objet dérivé peut être détruit avec delete à travers un pointeur vers la classe de base, le destructeur de cette classe doit être virtuel. • Une collection de pointeurs vers une classe de base peut désigner des objets appartenant à différentes classes dérivées. Un même appel de méthode peut alors produire des comportements différents selon l’objet rencontré.
Accueil
P2-Chap-04- Le polymorphisme
Accueil
Termes à ajouter au glossaire

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Aire v.1.0
Classe Forme initiale
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Aire v.2.0
Classe Forme et polymorphisme du calcul de l’aire de formes héritées de Forme
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Chapitre 5 - Surcharge d'opérateur

Chapitre 5 – Surcharge d’opérateur

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
5 Surcharge d’opérateur 5.1 Pourquoi surcharger un opérateur ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.1 Et pour nos propres classes ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.2 Une méthode classique serait possible . . . . . . . . . . . . . . . . . . . . . . 5.1.3 Surcharger ne signifie pas créer un nouvel opérateur . . . . . . . . . . . . . . 5.2 Une classe pour représenter les nombres complexes . . . . . . . . . . . . . . . . . . . 5.2.1 Implémentation de la classe . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.4 Que signifie réellement l’expression z1 + z2 ? . . . . . . . . . . . . . . . . . . . . . . 5.4.1 L’objet de gauche et l’objet de droite . . . . . . . . . . . . . . . . . . . . . . . 5.5 Surcharger les opérateurs – et * . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.1 Surcharger l’opérateur – . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.2 Surcharger l’opérateur * . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Combiner les opérateurs . . . . . . . . . . . . . . . . . . . . . . . . 5.6 Surcharger les opérateurs == et != . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.8 Retour sur l’opérateur d’affectation = . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.8.1 Surcharger l’opérateur d’affectation . . . . . . . . . . . . . . . . . . . . . . . . 5.8.2 Retourner l’objet qui vient d’être modifié . . . . . . . . . . . . . . . . . . . . 5.8.3 Les affectations en chaîne . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.8.4 Une surcharge ici essentiellement pédagogique . . . . . . . . . . . . . . . . . 5.9 Surcharger l’opérateur « . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.9.1 L’objet situé à gauche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.9.2 Une fonction opérateur extérieure à la classe . . . . . . . . . . . . . . . . . . 5.9.5 Une autorisation accordée par la classe . . . . . . . . . . . . . . . . . . . . . . 5.9.6 friend est-il indispensable ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.10 Quels opérateurs peut-on surcharger ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.10.1 Les opérateurs doivent déjà exister . . . . . . . . . . . . . . . . . . . . . . . . 5.10.2 Quelques opérateurs ne peuvent pas être surchargés . . . . . . . . . . . . . . 5.10.3 Les règles syntaxiques restent celles du C++ . . . . . . . . . . . . . . . . . . 5.10.3.1 Conserver une signification naturelle . . . . . . . . . . . . . . . . . La surcharge d’opérateur
Accueil
Surcharge d’opérateur Pourquoi surcharger un opérateur ? Les opérateurs du C++ permettent d’effectuer des opérations sur les types fondamentaux du langage. Nous pouvons par exemple écrire : L’opérateur + possède ici une signification parfaitement définie : il effectue l’addition de deux nombres Il en va de même pour d’autres types fondamentaux : Le C++ sait donc naturellement ce que signifie l’expression lorsque les objets manipulés appartiennent à des types pour lesquels cette opération est définie. Et pour nos propres classes ? Considérons maintenant une classe représentant un nombre complexe. Un nombre complexe peut s’écrire sous la forme où a représente la partie réelle et b la partie imaginaire. Nous pourrions construire une classe contenant ces deux valeurs : double partieReelle; double partieImaginaire; La surcharge d’opérateur Nous pouvons alors créer deux nombres complexes : Complexe z1(2.0, 3.0); Complexe z2(4.0, 5.0); Mathématiquement, leur somme est parfaitement définie : (2 + 3i ) + (4 + 5i ) = 6 + 8i . Nous aimerions donc naturellement pouvoir écrire : Complexe z3 = z1 + z2; Mais le compilateur ne peut pas deviner ce que l’opérateur + doit faire avec les objets que nous avons nous-mêmes définis. La classe Complexe est un nouveau type créé par le programmeur. Nous devons donc indiquer au compilateur quelle signification doit prendre l’opérateur + lorsqu’il est appliqué à deux objets de ce type. Cette opération porte le nom de surcharge d’opérateur. Une méthode classique serait possible Nous pourrions parfaitement définir une méthode : Complexe additionner(const Complexe& autre) const; Complexe z3 = z1.additionner(z2); Cette solution serait correcte. Cependant, dans le cas d’objets possédant une opération mathématique naturelle, l’écriture Complexe z3 = z1 + z2; est beaucoup plus proche de la notation habituelle : La surcharge d’opérateur permet donc notamment de donner aux objets créés par le programmeur une syntaxe similaire à celle des types fondamentaux. Surcharger ne signifie pas créer un nouvel opérateur Le C++ permet de redéfinir le comportement de nombreux opérateurs existants pour les objets appartenant à nos propres classes. Nous pouvons par exemple donner une signification à
Accueil
La surcharge d’opérateur Cependant, la surcharge d’opérateur ne permet pas de créer de nouveaux opérateurs. Nous ne pouvons pas inventer, par exemple, un opérateur si cette séquence ne constitue pas un opérateur défini par le langage. De même, la surcharge ne permet pas de modifier les règles syntaxiques associées à un opérateur existant. Sa priorité et son associativité restent celles définies par le langage. Ainsi, l’expression reste interprétée comme même si + et ∗ ont été surchargés pour les classes des objets A, B et C. Nous pouvons donc modifier la signification d’une opération pour nos objets, mais pas les règles grammaticales de l’opérateur lui-même. • La surcharge d’opérateur permet de définir le comportement d’un opérateur existant pour des objets appartenant à des classes créées par le programmeur. • Une expression telle que peut ainsi être utilisée avec des objets de type Complexe. • La surcharge d’opérateur n’est pas indispensable : une méthode classique peut généralement réaliser la même opération. Elle permet surtout d’obtenir une notation plus naturelle lorsque l’opérateur possède une signification logique pour les objets concernés. • Il n’est pas possible de créer de nouveaux opérateurs. • La surcharge ne modifie ni la priorité ni l’associativité des opérateurs existants. Une classe pour représenter les nombres complexes Pour étudier la surcharge d’opérateur, nous allons utiliser une classe représentant les nombres complexes. Un nombre complexe s’écrit sous la forme où a représente sa partie réelle et b sa partie imaginaire. Nous allons représenter ces deux valeurs par deux membres de type double :
Accueil
La surcharge d’opérateur double partieReelle; double partieImaginaire; double getPartieReelle() const; double getPartieImaginaire() const; void afficher() const; Cette classe ne contient pour l’instant aucune surcharge d’opérateur. Elle utilise uniquement des mécanismes déjà étudiés. Implémentation de la classe Le constructeur initialise les parties réelle et imaginaire à l’aide d’une liste d’initialisation : : partieReelle(reelle), partieImaginaire(imaginaire) Les deux accesseurs permettent de consulter les composantes du nombre complexe : double Complexe::getPartieReelle() const return partieReelle; double Complexe::getPartieImaginaire() const return partieImaginaire; Pour faciliter nos premiers essais, ajoutons une méthode d’affichage : void Complexe::afficher() const std::cout << partieReelle Nous pouvons maintenant écrire :
Accueil
Complexe z1(2.0, 3.0); Complexe z2(4.0, 5.0); La surcharge d’opérateur Le programme affiche : Nous disposons maintenant de deux objets : Complexe z1(2.0, 3.0); Complexe z2(4.0, 5.0); Nous savons mathématiquement calculer Nous aimerions donc pouvoir écrire directement : Complexe z3 = z1 + z2; Il reste à expliquer au compilateur ce que signifie l’opérateur + lorsqu’il est placé entre deux objets Surcharger l’opérateur + Nous souhaitons pouvoir additionner deux objets de type Complexe à l’aide de la notation naturelle : Complexe z3 = z1 + z2; Mathématiquement, si z 1 + z 2 = (a + c) + i (b + d ). Il suffit donc d’additionner séparément les parties réelles et les parties imaginaires. Nous allons définir dans la classe une fonction particulière appelée operator+ : const Complexe& autre La classe devient donc :
Accueil
La surcharge d’opérateur double partieReelle; double partieImaginaire; double getPartieReelle() const; double getPartieImaginaire() const; const Complexe& autre void afficher() const; L’implémentation de l’opérateur est très proche de celle d’une méthode classique : Complexe Complexe::operator+( const Complexe& autre + autre.partieReelle, + autre.partieImaginaire Dans cette méthode, deux objets interviennent. L’objet placé à gauche de l’opérateur est l’objet sur lequel la méthode est appelée. L’objet placé à droite est reçu dans le paramètre const Complexe& autre Ainsi, dans l’expression z1 correspond à l’objet courant, tandis que z2 correspond au paramètre autre. peut donc être comprise comme un appel équivalent à : L’opérateur + constitue ainsi une syntaxe particulière permettant d’appeler la fonction operator+().
Accueil
La surcharge d’opérateur Complexe z1(2.0, 3.0); Complexe z2(4.0, 5.0); Complexe z3 = z1 + z2; Le programme affiche : ce qui correspond bien à (2 + 3i ) + (4 + 5i ) = 6 + 8i . • Une surcharge d’opérateur est réalisée à l’aide d’une fonction dont le nom commence const Complexe& autre définit le comportement de l’opérateur + pour les objets de type Complexe. • Dans l’expression l’objet z1 est l’objet courant et z2 est transmis comme argument à operator+(). • Cette expression peut être comprise comme : • La fonction retourne ici un nouvel objet Complexe contenant le résultat de l’addition. Que signifie réellement l’expression z1 + z2 ? Complexe z3 = z1 + z2; peut donner l’impression que l’opérateur + possède un mécanisme particulier entièrement différent d’un appel de méthode classique. Ce n’est pas le cas. Lorsque operator+() est défini comme méthode membre de la classe Complexe, l’expression peut être comprise comme : Nous retrouvons donc exactement la structure d’un appel de méthode.
Accueil
La surcharge d’opérateur L’objet de gauche et l’objet de droite l’objet z1 est l’objet courant. À l’intérieur de la méthode, il est donc accessible implicitement à travers le pointeur this. L’objet z2, placé à droite de l’opérateur, est reçu dans le paramètre : const Complexe& autre Nous pouvons donc écrire conceptuellement : Complexe Complexe::operator+( const Complexe& autre + autre.partieReelle, this->partieImaginaire + autre.partieImaginaire Comme dans les autres méthodes membres, l’utilisation explicite de this−> n’est pas nécessaire. Les désignent ici le même membre de l’objet courant.Comme dans les autres méthodes membres, l’utilisation explicite de this−> n’est pas nécessaire. désignent ici le même membre de l’objet courant. Création du résultat L’opération ne modifie ni z1 ni z2. Elle construit un nouvel objet contenant la somme : partieReelle + autre.partieReelle, partieImaginaire + autre.partieImaginaire Le constructeur de Complexe est donc appelé pour créer l’objet résultat.
Accueil
La surcharge d’opérateur La fonction operator+() retourne un objet de type Complexe par valeur : const Complexe& autre Ce choix est naturel : le résultat de l’addition est un nouvel objet qui doit continuer à exister après la fin de l’appel de la méthode. Complexe z3 = z1 + z2; utilise donc l’objet retourné pour initialiser z3. est une syntaxe particulière correspondant ici à un appel de méthode : • L’objet de gauche est l’objet courant ; l’objet de droite est transmis comme argument. • L’opérateur ne modifie pas nécessairement ses opérandes. Dans notre cas, il construit un nouvel objet contenant le résultat. • Le mot-clé const placé après la méthode garantit que l’objet situé à gauche de l’opérateur n’est pas modifié. Surcharger les opérateurs – et * Une fois la surcharge de l’opérateur + comprise, les autres opérations arithmétiques se construisent de la même manière. Pour les nombres complexes, la soustraction et la multiplication possèdent toutes deux une signification mathématique naturelle. Nous allons donc permettre les écritures : Complexe z3 = z1 – z2; Complexe z4 = z1 * z2; Surcharger l’opérateur –
Accueil
La surcharge d’opérateur z 1 − z 2 = (a − c) + i (b − d ). Nous ajoutons donc à la classe Complexe : const Complexe& autre Son implémentation est : Complexe Complexe::operator-( const Complexe& autre – autre.partieReelle, – autre.partieImaginaire peut être comprise comme : L’objet z1 est l’objet courant et z2 est transmis à la méthode comme argument. Surcharger l’opérateur * La multiplication de deux nombres complexes demande un calcul légèrement plus élaboré. z 1 z 2 = (a + i b)(c + i d ). z 1 z 2 = ac + i ad + i bc + i 2 bd . z 1 z 2 = (ac − bd ) + i (ad + bc). Nous déclarons dans la classe :
Accueil
La surcharge d’opérateur const Complexe& autre puis nous définissons : Complexe Complexe::operator*( const Complexe& autre partieReelle * autre.partieReelle – partieImaginaire * autre.partieImaginaire; partieReelle * autre.partieImaginaire + partieImaginaire * autre.partieReelle; return Complexe(reelle, imaginaire); Nous pouvons maintenant écrire : Complexe z1(2.0, 3.0); Complexe z2(4.0, 5.0); Complexe somme = z1 + z2; Complexe difference = z1 – z2; Complexe produit = z1 * z2; difference.afficher(); 10 produit.afficher(); (2 + 3i )(4 + 5i ) = 8 + 10i + 12i + 15i 2 = −7 + 22i . Combiner les opérateurs Puisque les opérateurs ont maintenant été définis pour notre classe, ils peuvent être combinés dans des expressions plus complexes. Nous pouvons par exemple écrire : Complexe resultat = z1 + z2 * z3; Les règles habituelles de priorité des opérateurs restent applicables. L’opérateur ∗ étant prioritaire sur l’opérateur +, cette expression est interprétée comme :
Accueil
La surcharge d’opérateur Complexe resultat = z1 + (z2 * z3); La surcharge modifie donc le comportement des opérateurs pour nos objets, mais elle ne modifie pas les règles de priorité du langage. Nous pouvons même comprendre cette expression comme une succession d’appels de méthodes : z1.operator+(z2.operator*(z3)) La notation utilisant les opérateurs est évidemment beaucoup plus naturelle : • Les opérateurs − et ∗ se surchargent selon le même principe que l’opérateur +. peuvent être comprises respectivement comme : • Chaque opérateur doit recevoir une signification cohérente avec les objets manipulés. • La surcharge d’un opérateur ne modifie pas sa priorité. Ainsi, ∗ reste prioritaire sur + et • Les opérateurs surchargés peuvent être combinés pour former des expressions complexes utilisant une notation naturelle. Surcharger les opérateurs == et != Les opérateurs de comparaison peuvent également être surchargés. Pour deux nombres complexes nous considérerons qu’ils sont égaux si leurs parties réelles et imaginaires sont respectivement égales : Nous souhaitons donc pouvoir écrire :
Accueil
La surcharge d’opérateur L’opérateur != peut être défini à partir de l’opérateur == déjà disponible : bool Complexe::operator!=( const Complexe& autre return !(*this == autre); utilise l’opérateur == que nous venons de surcharger. L’opérateur logique ! inverse ensuite le résultat. • Les opérateurs de comparaison peuvent eux aussi être surchargés. • L’opérateur == retourne ici une valeur de type bool. peut être comprise comme • L’opérateur != peut être défini à partir de == afin d’éviter de répéter la même logique. • Une surcharge d’opérateur peut donc retourner un type différent de celui des objets Surcharger l’opérateur += Jusqu’à présent, les opérateurs +, − et ∗ construisent un nouvel objet contenant le résultat de l’opération. Complexe z3 = z1 + z2; ne modifie ni z1 ni z2. L’opérateur += possède un comportement différent. L’instruction doit modifier directement l’objet z1. L’opérateur retourne une référence vers l’objet courant : La valeur retournée est :
Accueil
La surcharge d’opérateur c’est-à-dire l’objet sur lequel l’opérateur a été appliqué. • L’opérateur += modifie l’objet placé à gauche. • Contrairement à +, il ne construit pas nécessairement un nouvel objet contenant le • La surcharge retourne généralement une référence vers l’objet courant : Complexe& operator+=( const Complexe& autre retourne l’objet courant lui-même. • Ce choix permet notamment les opérations en chaîne. Retour sur l’opérateur d’affectation = L’opérateur d’affectation = peut lui aussi être surchargé. Considérons deux objets de notre classe Complexe z1(2.0, 3.0); Complexe z2(5.0, 7.0); affecte les valeurs contenues dans z2 à l’objet z1. Comme pour les autres opérateurs étudiés dans ce chapitre, cette écriture peut être comprise comme un appel de méthode : Surcharger l’opérateur d’affectation Nous pouvons déclarer l’opérateur d’affectation dans la classe Complexe : Complexe& operator=( const Complexe& autre Son implémentation peut être écrite : Complexe& Complexe::operator=( const Complexe& autre partieReelle = autre.partieReelle; partieImaginaire = autre.partieImaginaire;
Accueil
La surcharge d’opérateur z1 est l’objet courant. contient donc l’adresse de z1. L’objet z2 est transmis à la méthode par l’intermédiaire du paramètre : const Complexe& autre Les deux affectations partieReelle = autre.partieReelle; partieImaginaire = autre.partieImaginaire; modifient ainsi les membres de l’objet courant. Retourner l’objet qui vient d’être modifié La méthode retourne : Elle retourne donc une référence vers un objet de type Complexe, et non une copie de cet objet. déréférence le pointeur this et désigne ainsi l’objet courant lui-même. Dans notre exemple, ∗this désigne donc z1. L’opérateur retourne ainsi une référence vers l’objet qui vient d’être modifié. ne crée pas un nouvel objet Complexe. Il retourne une référence vers l’objet courant. Les affectations en chaîne Le retour d’une référence vers l’objet courant permet notamment d’écrire : L’opération est effectuée en commençant par : Cette opération modifie z2 puis retourne une référence vers z2. Cette référence peut alors être utilisée pour effectuer :
Accueil
La surcharge d’opérateur Les trois objets contiennent finalement les mêmes valeurs. Une surcharge ici essentiellement pédagogique Dans notre classe Complexe, les deux membres sont de type double : double partieReelle; double partieImaginaire; Le compilateur est parfaitement capable de réaliser lui-même leur affectation. Même si nous n’écrivions aucun operator=(), l’instruction fonctionnerait donc correctement. Nous avons ici écrit explicitement cet opérateur afin d’en comprendre le fonctionnement. Pour certaines classes gérant directement des ressources, une définition particulière de l’opérateur d’affectation peut en revanche devenir nécessaire. Nous avons déjà rencontré cette situation avec une classe gérant une zone de mémoire allouée dynamiquement : copier uniquement la valeur d’un pointeur ne suffit pas lorsque nous voulons obtenir une véritable copie indépendante des données. La surcharge de l’opérateur d’affectation permet alors de définir précisément le comportement souhaité. • L’opérateur d’affectation = peut être surchargé comme les autres opérateurs. peut être comprise comme : • L’opérateur d’affectation modifie l’objet situé à gauche. • Il retourne généralement une référence vers cet objet : • Le type de retour Complexe& évite de retourner une copie de l’objet. • Pour une classe simple comme Complexe, l’opérateur d’affectation fourni automatiquement par le compilateur est suffisant. Surcharger l’opérateur « Notre classe Complexe possède actuellement une méthode afficher() permettant d’écrire :
Accueil
Complexe z(2.0, 3.0); La surcharge d’opérateur Nous aimerions cependant utiliser la syntaxe habituelle du C++ : et obtenir par exemple : Pour y parvenir, nous devons surcharger l’opérateur <<. L’objet situé à gauche Jusqu’à présent, les opérateurs surchargés étaient définis comme des méthodes de la classe Complexe. pouvait être compris comme : L’objet situé à gauche de l’opérateur était donc un objet de type Complexe. Considérons maintenant : L’objet situé à gauche de l’opérateur n’est plus z, mais std::cout. Il n’est donc pas possible de traiter cette expression comme un appel d’une méthode operator<<() de notre classe Complexe. Une fonction opérateur extérieure à la classe Un opérateur surchargé ne doit pas nécessairement être une méthode membre d’une classe. Nous pouvons définir une fonction extérieure : std::ostream& operator<<( std::ostream& sortie, Cette fois, les deux opérandes apparaissent explicitement comme paramètres de la fonction.
Accueil
La surcharge d’opérateur • Un opérateur surchargé peut être implémenté par une méthode membre ou par une fonction extérieure à la classe. • Pour une méthode membre, l’opérande de gauche correspond à l’objet courant. • Dans l’expression l’opérande de gauche est std::cout et non l’objet z. • Nous pouvons donc définir une fonction extérieure : std::ostream& operator<<( std::ostream& sortie, • Les deux opérandes apparaissent alors explicitement comme paramètres de la fonction. Nous avons défini l’opérateur << comme une fonction extérieure à la classe Complexe : std::ostream& operator<<( std::ostream& sortie, sortie << z.getPartieReelle() << z.getPartieImaginaire(); Cette fonction peut utiliser les méthodes publiques de la classe, mais elle ne possède aucun accès particulier à ses membres privés. Par exemple, l’écriture suivante est normalement interdite : cout << z.partieReelle; car partieReelle est un membre privé de Complexe. Une classe peut autoriser explicitement une fonction extérieure à accéder à ses membres privés. Cette autorisation est donnée à l’aide du mot-clé friend : friend std::ostream& operator<<( std::ostream& sortie, La fonction operator<<() reste une fonction extérieure à la classe. Elle ne devient pas une méthode de Complexe. Elle reçoit cependant l’autorisation d’accéder aux membres privés de ses objets.
Accueil
La surcharge d’opérateur Une autorisation accordée par la classe Le mot-clé friend apparaît dans la déclaration de la classe qui accorde l’accès. La fonction extérieure ne peut donc pas décider elle-même qu’elle possède un accès aux membres privés d’une classe. C’est la classe qui désigne explicitement les fonctions auxquelles elle accorde ce privilège. friend est-il indispensable ? Dans notre exemple, l’utilisation de friend n’est pas indispensable. Puisque la classe possède des accesseurs publics, nous pouvons parfaitement écrire : sortie << z.getPartieReelle() << z.getPartieImaginaire(); L’utilisation de friend permet simplement à la fonction operator<<() d’accéder directement à la représentation interne de l’objet sans rendre ses membres publics. Elle doit donc correspondre à un choix volontaire de conception. • Une fonction extérieure ne peut normalement pas accéder aux membres privés d’une • Une classe peut accorder cette autorisation à une fonction en la déclarant avec le mot-clé • Une fonction friend reste une fonction extérieure : elle ne devient pas une méthode membre de la classe. • Dans notre exemple, la déclaration friend std::ostream& operator<<( std::ostream& sortie, permet à operator<<() d’accéder directement aux membres privés de Complexe. • L’amitié ne rend pas les membres privés publics. Elle accorde un accès particulier uniquement à la fonction déclarée amie. • L’utilisation de friend n’est pas obligatoire lorsque l’interface publique de la classe fournit déjà les opérations nécessaires. Quels opérateurs peut-on surcharger ? Le C++ permet de surcharger un grand nombre de ses opérateurs. Parmi ceux-ci, nous trouvons
Accueil
La surcharge d’opérateur Cette possibilité ne signifie cependant pas que le programmeur puisse modifier librement la syntaxe du langage. La surcharge d’opérateur est soumise à plusieurs règles. Les opérateurs doivent déjà exister La surcharge permet de donner une signification particulière à un opérateur existant lorsqu’il est utilisé avec des objets appartenant à nos propres types. Elle ne permet pas de créer de nouveaux opérateurs. Nous ne pouvons donc pas inventer, par exemple, si cette combinaison ne correspond pas à un opérateur défini par le langage. Quelques opérateurs ne peuvent pas être surchargés Certains opérateurs et constructions du langage ne peuvent pas être surchargés. C’est notamment le cas de : Le programmeur ne peut donc pas modifier leur comportement pour ses propres classes. Les règles syntaxiques restent celles du C++ La surcharge d’un opérateur ne permet pas de modifier sa priorité. reste interprété comme : même si les opérateurs + et ∗ ont été surchargés. La surcharge ne permet pas non plus de modifier l’associativité d’un opérateur. Les règles syntaxiques du langage restent donc indépendantes du comportement que nous donnons à l’opérateur. Conserver une signification naturelle Le langage permet parfois de donner à un opérateur un comportement qui n’a aucun rapport avec sa signification habituelle. Il serait par exemple techniquement possible de définir l’opérateur + de manière à effectuer une soustraction. Une telle surcharge serait cependant extrêmement difficile à comprendre pour le lecteur du programme. Une surcharge d’opérateur doit donc conserver une signification cohérente et naturelle pour les objets manipulés. Pour notre classe Complexe, les expressions
Accueil
La surcharge d’opérateur possèdent une interprétation mathématique immédiate. La surcharge améliore alors la lisibilité du programme au lieu de la dégrader. • De nombreux opérateurs du C++ peuvent être surchargés, mais pas tous. • Il n’est pas possible d’inventer de nouveaux opérateurs. • La surcharge ne modifie ni la priorité ni l’associativité d’un opérateur. • Une surcharge doit conserver une signification naturelle et prévisible pour les objets auxquels elle s’applique. • Le fait qu’une surcharge soit techniquement possible ne signifie pas qu’elle soit souhaitable. La surcharge d’opérateur permet aux classes créées par le programmeur d’utiliser certains opérateurs du C++ avec une syntaxe proche de celle des types fondamentaux. Notre classe Complexe nous a ainsi permis d’écrire : Complexe z3 = z1 + z2; Complexe z4 = z1 − z2; Complexe z5 = z1 ∗ z2; Ces expressions utilisent toutes des fonctions opérateurs que nous avons définies pour notre propre classe. Une expression utilisant un opérateur surchargé correspond à l’appel d’une fonction. Lorsque l’opérateur est défini comme méthode membre, une expression telle que peut être comprise comme : L’objet situé à gauche est alors l’objet courant et l’objet situé à droite est transmis comme argument. Un opérateur peut également être défini par une fonction extérieure. Nous avons rencontré cette L’opérande de gauche étant ici std::cout, nous avons défini une fonction extérieure : std::ostream& operator<<(
Accueil
La surcharge d’opérateur std::ostream& sortie, Les deux opérandes apparaissent alors explicitement comme paramètres de la fonction. Nous avons enfin vu qu’une classe peut accorder à une fonction extérieure un accès à ses membres privés en la déclarant friend. Cette fonction reste extérieure à la classe : elle reçoit simplement une autorisation particulière d’accès à sa représentation interne. • La surcharge d’opérateur permet de définir le comportement d’un opérateur existant pour les objets d’une classe. • Un opérateur surchargé est fondamentalement une fonction possédant un nom particulier • Une surcharge peut être définie comme méthode membre ou, lorsque cela est approprié, comme fonction extérieure. • Les opérateurs tels que + construisent généralement un nouvel objet résultat, tandis que des opérateurs tels que += ou = modifient l’objet situé à gauche. • Une fonction qui modifie l’objet courant peut retourner une référence vers celui-ci avec : • Le choix d’une surcharge est effectué à partir des types des opérandes. Il ne dépend pas des valeurs qu’ils contiennent. • Le mot-clé friend permet à une classe d’accorder à une fonction extérieure l’accès à ses membres privés. • La surcharge d’opérateur doit être utilisée lorsqu’elle rend l’écriture du programme plus naturelle et plus lisible. La surcharge d’opérateur ne crée donc pas une nouvelle forme de calcul : elle permet de donner une syntaxe naturelle à des appels de fonctions définis pour nos propres types.
Accueil
P2-Chap-05- La surcharge d’opérateur
Accueil
Termes à ajouter au glossaire

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Complexe
Classe Complexe et surcharge d’opérations sur les nombres complexes
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Chapitre 6 - Composition et relation entre classe

Chapitre 6 – Composition et relation entre classe

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
6 Composition et relations entre classes 6.1 Une classe peut contenir des objets . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.1.1 Une relation différente de l’héritage . . . . . . . . . . . . . . . . . . . . . . . . 6.2.2 Utiliser l’objet membre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.3 Composition et héritage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.4 Construction des objets membres . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.5 Une première tentative . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.6 La liste d’initialisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.7 Construire n’est pas affecter . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.8 Observer l’ordre de construction . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.9 Ordre de construction des objets membres . . . . . . . . . . . . . . . . . . . . 6.2.10 Qui décide de l’ordre ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.11 Un ordre fixé par la classe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.12 Respecter l’ordre de déclaration . . . . . . . . . . . . . . . . . . . . . . . . . . 6.3 Ordre de destruction des objets membres . . . . . . . . . . . . . . . . . . . . . . . . . 6.3.1 Construction et destruction . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.3.2 Le corps du destructeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.3.3 La destruction des membres est automatique . . . . . . . . . . . . . . . . . . 6.3.4 Un destructeur explicite n’est pas nécessaire . . . . . . . . . . . . . . . . . . . 6.4 Composition ou héritage ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.4.2 La relation « possède un » . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.4.3 Un héritage techniquement possible mais conceptuellement faux . . . . . . . 6.4.4 Un héritage techniquement possible mais conceptuellement faux . . . . . . . 6.4.5 Combiner héritage et composition . . . . . . . . . . . . . . . . . . . . . . . . . 6.4.6 Construire des objets complexes par assemblage . . . . . . . . . . . . . . . . . 6.5 Plusieurs objets membres et délégation des tâches . . . . . . . . . . . . . . . . . . . . 6.5.1 Déléguer une responsabilité . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6 Composition et pointeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6.1 Deux situations différentes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6.2 Un objet créé dynamiquement . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6.3 La responsabilité de la destruction . . . . . . . . . . . . . . . . . . . . . . . . 6.6.4 Accéder à l’objet pointé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6.5 Pointer vers un objet existant . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6.6 Une information absente du pointeur classique . . . . . . . . . . . . . . . . . 6.6.8 Un objet existant indépendamment . . . . . . . . . . . . . . . . . . . . . . . . 6.6.10 Le pointeur ne définit pas à lui seul la relation . . . . . . . . . . . . . . . . . 6.6.11 Utiliser une référence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – Partie II C++ Composition et relations entre classe Comparaison des relations entre classes . . . . . . . . . . . . . . . . . . . . . . . . . . 6.7.2 La composition : « possède un » . . . . . . . . . . . . . . . . . . . . . . . . . 6.7.3 L’agrégation : utiliser un objet indépendant . . . . . . . . . . . . . . . . . . . 6.7.4 Plusieurs relations dans une même classe . . . . . . . . . . . . . . . . . . . . . 6.7.5 Trois questions pour identifier la relation . . . . . . . . . . . . . . . . . . . . .
Accueil
Composition et relations entre Une classe peut contenir des objets Jusqu’à présent, les membres de nos classes étaient souvent des objets appartenant aux types fondamentaux du C++. Nous avons par exemple utilisé : double partieReelle; double partieImaginaire; Mais un membre d’une classe peut lui-même être un objet appartenant à une autre classe. Considérons une classe représentant un moteur : Moteur(double puissanceInitiale); double getPuissance() const; Nous pouvons maintenant utiliser un objet de type Moteur comme membre d’une autre classe : Voiture(double puissance); 2026 – Partie II C++ Composition et relations entre classe Un objet de type Voiture contient donc réellement un objet de type Moteur. Nous pouvons représenter cette organisation de manière simplifiée : L’objet Moteur fait ici partie de l’objet Voiture. Cette relation entre classes porte le nom de composition. Une relation différente de l’héritage La composition ne doit pas être confondue avec l’héritage. L’héritage exprime généralement une un carré est une forme. Nous pouvons donc écrire : class Carre : public Forme La composition exprime au contraire une relation du type : Une voiture n’est pas un moteur. Une voiture possède un moteur. Nous écrivons donc : • Un membre d’une classe peut lui-même être un objet appartenant à une autre classe. • Lorsqu’un objet contient directement un autre objet comme l’un de ses membres, nous obtenons une relation de composition. • L’héritage exprime généralement une relation « est un ». • La composition exprime généralement une relation « possède un ».
Accueil
2026 – Partie II C++ Composition et relations entre classe La relation « possède un » La composition permet de représenter une relation dans laquelle un objet est constitué, entre autres, d’un ou de plusieurs autres objets. Dans notre exemple, une voiture possède un moteur : Le membre moteur n’est pas un pointeur vers un objet Moteur situé ailleurs. Il s’agit véritablement d’un objet de type Moteur contenu dans l’objet de type Voiture. Considérons la création d’une voiture : Voiture voiture(…); L’objet voiture contient lui-même son membre moteur. Nous pouvons nous représenter très schématiquement la situation ainsi : Le moteur fait donc partie de l’état de la voiture. Puisque le moteur est lui-même un objet, il possède sa propre durée de vie. Il doit être construit lorsque la voiture est construite et il doit être détruit lorsque la voiture est détruite. La composition établit donc également une relation entre les durées de vie des deux objets. Utiliser l’objet membre Les méthodes de Voiture peuvent naturellement utiliser l’objet moteur qu’elle contient. Nous pouvons par exemple écrire : double Voiture::getPuissance() const return moteur.getPuissance(); La méthode getPuissance() de Voiture demande donc à son objet moteur de fournir sa puissance.
Accueil
2026 – Partie II C++ Composition et relations entre classe voiture.getPuissance() moteur.getPuissance() La composition ne supprime pas l’encapsulation. La classe Voiture possède un objet Moteur, mais cela ne lui donne pas pour autant un accès particulier aux membres privés de la classe Moteur. Si puissance est privée dans Moteur, la classe Voiture doit utiliser l’interface publique de Moteur Composition et héritage La différence entre héritage et composition ne concerne pas seulement la syntaxe. class Carre : public Forme un objet Carre peut être considéré comme une forme particulière. Avec une composition : une voiture ne devient pas un moteur. Elle utilise simplement un moteur comme l’un des éléments • Dans une composition, l’objet membre fait réellement partie de l’objet qui le contient. • L’objet membre conserve néanmoins son propre type et son propre comportement. • La classe contenant un objet ne reçoit aucun accès particulier aux membres privés de cet objet. Elle utilise son interface publique. • Lorsque l’objet membre est directement contenu dans un autre objet, leurs durées de • La question « est un » conduit généralement vers l’héritage, tandis que la question « possède un » conduit généralement vers la composition.
Accueil
2026 – Partie II C++ Composition et relations entre classe Construction des objets membres Notre classe Voiture contient directement un objet de type Moteur : Voiture(double puissance); Supposons maintenant que la classe Moteur possède le constructeur suivant : Moteur::Moteur(double puissanceInitiale) : puissance(puissanceInitiale) La création d’un objet Voiture pose alors une question : comment construire le moteur contenu dans la voiture ? Une première tentative Nous pourrions être tentés d’écrire : Voiture::Voiture(double puissance) moteur = Moteur(puissance); Cette écriture arrive cependant trop tard. Lorsque l’exécution entre dans le corps du constructeur de Voiture, tous les membres de l’objet doivent déjà avoir été construits. L’objet moteur doit donc exister avant l’exécution de : moteur = Moteur(puissance); La liste d’initialisation Le constructeur de Voiture doit indiquer comment construire son membre moteur. Pour cela, nous utilisons la liste d’initialisation : Voiture::Voiture(double puissance) demande au constructeur de Voiture de construire le membre moteur en appelant le constructeur de Moteur avec l’argument puissance.
Accueil
2026 – Partie II C++ Composition et relations entre classe Construire n’est pas affecter Il faut distinguer deux opérations fondamentalement différentes. construit directement l’objet moteur avec la valeur souhaitée. En revanche, une écriture placée dans le corps du constructeur : moteur = Moteur(puissance); est une affectation. Pour pouvoir effectuer cette affectation, moteur devrait donc déjà avoir été construit auparavant. La liste d’initialisation permet de construire directement l’objet membre dans son état initial. Observer l’ordre de construction Nous pouvons ajouter temporairement un affichage dans les deux constructeurs. Voiture voiture(150.0); Construction du moteur Construction de la voiture Le résultat est logique : avant que l’objet Voiture puisse être considéré comme construit, tous les objets qui le composent doivent eux-mêmes avoir été construits. • Un objet membre doit être construit avant l’exécution du corps du constructeur de l’objet • La liste d’initialisation permet d’indiquer directement quel constructeur doit être utilisé pour construire un objet membre. Voiture::Voiture(double puissance) l’expression moteur(puissance) appelle le constructeur de Moteur. • Une initialisation et une affectation sont deux opérations différentes : l’affectation suppose que l’objet existe déjà. • Avant l’exécution du corps d’un constructeur, les objets membres ont déjà été construits. REPRENDRE à 17.8 COMPOSITIONS ET POINTEURS
Accueil
2026 – Partie II C++ Composition et relations entre classe Ordre de construction des objets membres Une classe peut naturellement contenir plusieurs objets membres. Ajoutons à notre voiture une La classe Voiture contient maintenant deux objets : Voiture(double puissance); #include « Voiture.h » Voiture voiture(150.0); Construction du moteur Construction de la batterie Construction de la voiture Program ended with exit code: 0 Qui décide de l’ordre ? L’ordre de construction des objets membres n’est pas déterminé par leur ordre dans la liste d’initialisation. Il est déterminé par leur ordre de déclaration dans la classe.
Accueil
2026 – Partie II C++ Composition et relations entre classe Voiture(double puissance); moteur est déclaré avant batterie. Le moteur sera donc toujours construit avant la batterie. Un ordre fixé par la classe Cette règle garantit que l’ordre de construction des membres est toujours le même, quel que soit le constructeur utilisé. L’ordre est donc déterminé une seule fois par la déclaration des membres dans la classe. Ainsi : signifie que moteur sera construit avant batterie dans tous les constructeurs de Voiture. Respecter l’ordre de déclaration Même si l’ordre écrit dans la liste d’initialisation ne détermine pas l’ordre réel de construction, il est fortement recommandé d’utiliser le même ordre que celui des déclarations. On préférera donc : Voiture::Voiture(double puissance) : moteur(puissance), si les membres ont été déclarés dans cet ordre. Le code reflète alors directement l’ordre réel des opérations. • Les objets membres sont construits avant l’exécution du corps du constructeur de l’objet • Lorsque plusieurs objets membres sont présents, leur ordre de construction est déterminé par leur ordre de déclaration dans la classe. • L’ordre écrit dans la liste d’initialisation ne modifie pas cet ordre de construction. • Il est donc recommandé d’écrire la liste d’initialisation dans le même ordre que les déclarations des membres. • Cette règle garantit un ordre de construction unique et prévisible, quel que soit le constructeur utilisé. Ordre de destruction des objets membres Nous avons constaté que les objets membres sont construits avant l’objet qui les contient.
Accueil
2026 – Partie II C++ Composition et relations entre classe La destruction suit la logique inverse. Pour observer ce mécanisme, ajoutons temporairement un destructeur à chacune de nos classes. Construction et destruction L’ordre de destruction est l’inverse de l’ordre de construction. Dans notre exemple, les membres de Voiture sont déclarés dans l’ordre : La construction s’effectue donc selon l’ordre : Moteur −→ Batterie −→ Voiture Lors de la destruction, l’ordre est inversé : Voiture −→ Batterie −→ Moteur Le corps du destructeur Lorsqu’un objet Voiture est détruit, le corps de son destructeur est exécuté en premier : << « Destruction de la voiture » Une fois ce corps exécuté, C++ détruit automatiquement les objets membres, dans l’ordre inverse de leur construction. Ainsi, batterie est détruite avant moteur. La destruction des membres est automatique Le destructeur de Voiture n’a pas à appeler explicitement les destructeurs de Moteur et de Batterie. C++ détruit automatiquement les objets membres lorsque l’objet qui les contient arrive en fin de vie. Cette propriété constitue l’un des grands intérêts de la composition : la durée de vie des objets directement contenus est naturellement liée à celle de l’objet qui les contient. Un destructeur explicite n’est pas nécessaire Dans notre exemple, nous avons écrit explicitement les destructeurs afin d’observer l’ordre des opérations. Ils ne sont pas nécessaires au fonctionnement de la composition. Si nous ne définissons aucun destructeur particulier pour Voiture, le destructeur généré automatiquement par le compilateur se chargera néanmoins de détruire correctement ses objets membres.
Accueil
2026 – Partie II C++ Composition et relations entre classe • Les objets membres sont détruits automatiquement lorsque l’objet qui les contient est • Le corps du destructeur de l’objet contenant est exécuté avant la destruction de ses • Les objets membres sont ensuite détruits dans l’ordre inverse de leur construction. • Si les membres sont déclarés dans l’ordre : ils sont construits dans l’ordre : Moteur −→ Batterie et détruits dans l’ordre : Batterie −→ Moteur. • Il n’est pas nécessaire d’appeler explicitement les destructeurs des objets membres : C++ s’en charge automatiquement. Composition ou héritage ? L’héritage et la composition permettent tous deux de construire de nouvelles classes à partir de classes existantes. Ils représentent cependant deux relations fondamentalement différentes. Le choix entre ces deux mécanismes ne doit donc pas être déterminé par la seule possibilité technique de les utiliser, mais par la relation réelle entre les objets que nous voulons représenter. La relation « est un » L’héritage convient lorsqu’un objet constitue une forme particulière d’un autre objet. Nous avons par exemple défini : class Carre : public Forme Cette relation possède une signification naturelle : Un carré est une forme. Un objet Carre peut donc être utilisé lorsqu’un objet Forme est attendu. Cette propriété nous a notamment permis d’utiliser le polymorphisme. La relation « possède un » La composition convient lorsqu’un objet utilise un autre objet comme l’un des éléments qui le constituent. Dans notre exemple :
Accueil
2026 – Partie II C++ Composition et relations entre classe Une voiture possède un moteur. Une voiture possède une batterie. Une voiture n’est cependant ni un moteur ni une batterie. Il s’agit donc d’une relation de composition et non d’une relation d’héritage. POSSÈDE UN −→ composition Un héritage techniquement possible mais conceptuellement faux Le compilateur ne connaît pas la signification réelle des classes que nous créons. Nous pourrions donc techniquement écrire : class Voiture : public Moteur Cette déclaration indique cependant : Une voiture est un moteur. Cette relation est fausse. Le fait qu’une voiture utilise un moteur ne justifie donc absolument pas un La relation correcte est : Un héritage techniquement possible mais conceptuellement faux Le compilateur ne connaît pas la signification réelle des classes que nous créons. Nous pourrions donc techniquement écrire : class Voiture : public Moteur
Accueil
2026 – Partie II C++ Composition et relations entre classe Cette déclaration indique cependant : Une voiture est un moteur. Cette relation est fausse. Le fait qu’une voiture utilise un moteur ne justifie donc absolument pas un La relation correcte est : Combiner héritage et composition L’héritage et la composition ne sont pas des mécanismes concurrents et ils peuvent parfaitement être utilisés simultanément lorsque les relations entre les objets le justifient. Nous pourrions par exemple définir plusieurs types de moteurs : class MoteurThermique : public Moteur class MoteurElectrique : public Moteur un moteur thermique est un moteur, un moteur électrique est un moteur. Il s’agit donc d’héritage. Une voiture peut ensuite être composée d’un moteur : class VoitureElectrique MoteurElectrique moteur; Nous avons maintenant simultanément :
Accueil
2026 – Partie II C++ Composition et relations entre classe Les deux relations décrivent des aspects différents de l’organisation des classes. Construire des objets complexes par assemblage La composition permet de construire progressivement des objets complexes à partir d’objets plus simples et spécialisés. Une voiture pourrait par exemple être représentée par : SystemeFreinage freins; Climatisation climatisation; OrdinateurDeBord ordinateur; Chacune de ces classes peut elle-même contenir d’autres objets. Nous pouvons ainsi construire un système complexe sous la forme d’un assemblage de composants dont chacun possède une responsabilité • L’héritage et la composition représentent deux relations différentes entre les classes. • L’héritage correspond généralement à une relation « est un ». • La composition correspond généralement à une relation « possède un ». • Un objet d’une classe dérivée peut être considéré comme un objet de sa classe de base. La composition ne crée pas cette relation de substitution. • Le fait qu’un héritage soit accepté par le compilateur ne signifie pas qu’il représente correctement la relation entre les objets. • Héritage et composition peuvent être combinés dans une même architecture. • La composition permet de construire des objets complexes par assemblage d’objets plus simples possédant chacun une responsabilité précise. Plusieurs objets membres et délégation des tâches Notre classe Voiture contient maintenant deux objets membres :
Accueil
2026 – Partie II C++ Composition et relations entre classe double getPuissance() const; double getCapaciteBatterie() const; : moteur(puissance), double Voiture::getPuissance() const return moteur.getPuissance(); double Voiture::getCapaciteBatterie() const return batterie.getCapacite(); La voiture ne stocke pas directement la puissance du moteur ni la capacité de la batterie. Ces informations appartiennent respectivement aux objets Moteur et Batterie. de Voiture ne connaît pas directement la représentation interne du moteur. Elle demande simplement à l’objet moteur de fournir son information : return moteur.getPuissance(); De même, la capacité de la batterie est obtenue par : return batterie.getCapacite(); La classe Voiture coordonne donc plusieurs objets spécialisés sans avoir à connaître leur représentation interne. Déléguer une responsabilité Lorsqu’une méthode d’une classe utilise un objet membre pour effectuer une opération qui relève naturellement de cet objet, on parle souvent de délégation. Dans notre exemple : Voiture −→ Moteur pour obtenir la puissance, et
Accueil
2026 – Partie II C++ Composition et relations entre classe Voiture −→ Batterie pour obtenir la capacité. Chaque classe conserve ainsi une responsabilité bien définie. #include « Voiture.h » Voiture voiture(150.0, 75.0); << voiture.getPuissance() << « Capacite batterie :  » << voiture.getCapaciteBatterie() Capacite batterie : 75 Program ended with exit code: 0 • Une classe peut contenir plusieurs objets membres appartenant à des classes différentes. • Chaque objet membre conserve sa propre responsabilité et sa propre encapsulation. • La classe contenant ces objets peut déléguer certaines opérations à ses composants. • La composition permet ainsi de construire des objets complexes à partir de composants spécialisés sans dupliquer leurs données ou leurs comportements. Composition et pointeurs Jusqu’à présent, notre classe Voiture contenait directement un objet de type Moteur :
Accueil
2026 – Partie II C++ Composition et relations entre classe L’objet moteur fait alors réellement partie de l’objet Voiture. Sa construction et sa destruction sont automatiquement liées à celles de la voiture. Nous pouvons cependant rencontrer une organisation différente dans laquelle la classe ne contient plus directement un objet, mais un pointeur vers cet objet : Ces deux déclarations ne décrivent pas la même situation. Deux situations différentes Le membre moteur est ici un véritable objet de type Moteur. déclare seulement un pointeur capable de contenir l’adresse d’un objet de type Moteur et l’objet Moteur lui-même doit exister quelque part ailleurs. Fig. 6.1 – Composition directe Fig. 6.2 – Avec un pointeur. Un objet créé dynamiquement Supposons que le constructeur de Voiture crée lui-même le moteur par allocation dynamique : Voiture::Voiture(double puissance) moteur = new Moteur(puissance);
Accueil
2026 – Partie II C++ Composition et relations entre classe new Moteur(puissance) crée dynamiquement un objet de type Moteur et retourne son adresse. Cette adresse est ensuite enregistrée dans le membre moteur. Nous pouvons également initialiser directement le pointeur dans la liste d’initialisation : Voiture::Voiture(double puissance) : moteur(new Moteur(puissance)) Le membre moteur reçoit alors directement l’adresse retournée par new. La responsabilité de la destruction Lorsque l’objet Voiture est détruit, son membre moteur est lui aussi détruit automatiquement. Mais ce membre est maintenant un pointeur donc la disparition du pointeur ne provoque pas la destruction de l’objet vers lequel il pointe. Si la voiture a créé elle-même le moteur avec new, elle doit donc également assurer sa destruction avec delete. Dans cette situation, le destructeur de Voiture n’est plus présent uniquement pour observer la destruction. Il possède une véritable responsabilité : libérer l’objet qui a été créé dynamiquement Accéder à l’objet pointé Puisque moteur est maintenant un pointeur, l’accès à ses méthodes s’effectue avec l’opérateur −>. double Voiture::getPuissance() const return moteur->getPuissance(); utilise donc l’objet Moteur dont l’adresse est contenue dans le pointeur moteur. Pointer vers un objet existant Un pointeur membre ne signifie pas nécessairement que la classe possède l’objet pointé. Un moteur pourrait avoir été créé indépendamment : Moteur moteurExterne(150.0); puis son adresse transmise à une voiture : Voiture voiture(&moteurExterne); La voiture peut alors utiliser cet objet sans l’avoir créé elle-même. Dans cette situation, la classe Voiture ne doit pas décider automatiquement de détruire l’objet pointé. Elle possède le pointeur, mais elle ne possède pas nécessairement l’objet vers lequel ce pointeur conduit. Il faut donc distinguer soigneusement :
Accueil
2026 – Partie II C++ Composition et relations entre classe posséder un pointeur être responsable de l’objet pointé. Une information absente du pointeur classique indique que moteur peut contenir l’adresse d’un objet de type Moteur mais n’indique cependant pas qui est responsable de la durée de vie de celui-ci. Cette information doit être déterminée par la conception du programme. Une mauvaise interprétation de cette responsabilité peut conduire soit à une fuite mémoire, soit à la destruction incorrecte d’un objet qui ne devait pas être détruit par cette classe. Objet directement contenu Destruction par Voiture Moteur* moteur; + new Moteur* moteur; + objet externe • Un objet membre déclaré directement : est automatiquement construit et détruit avec l’objet qui le contient. • Un membre déclaré : n’est qu’un pointeur vers un objet de type Moteur. • Détruire un pointeur ne détruit pas automatiquement l’objet vers lequel il pointe. • Si une classe crée elle-même un objet avec new, elle doit assurer sa destruction avec delete dans cette approche classique. • Un pointeur classique n’indique pas qui possède l’objet pointé ni qui est responsable de • Un pointeur peut également désigner un objet appartenant à une autre partie du programme. Dans ce cas, la classe qui utilise ce pointeur ne doit pas nécessairement détruire Nous avons jusqu’à présent étudié une composition dans laquelle un objet Moteur faisait directement partie d’un objet Voiture :
Accueil
2026 – Partie II C++ Composition et relations entre classe La durée de vie du moteur est alors directement liée à celle de la voiture : lorsque la voiture est détruite, son moteur est automatiquement détruit. Il existe cependant des relations plus faibles entre les objets. Une classe peut utiliser un objet qui existe indépendamment d’elle et dont elle ne contrôle pas nécessairement la durée de vie. On parle alors généralement d’agrégation. Un objet existant indépendamment Considérons un moteur créé indépendamment de la voiture : Moteur moteurExterne(150.0); Nous pouvons ensuite transmettre son adresse à un objet Voiture : Voiture voiture(&moteurExterne); La voiture peut maintenant utiliser ce moteur, mais elle ne l’a pas créé. Le moteur possède donc une existence indépendante de celle de la voiture. Le constructeur de Voiture ne construit pas ici un nouvel objet Moteur : Voiture::Voiture(Moteur* moteurExterne) : moteur(moteurExterne) Il enregistre simplement l’adresse d’un objet existant. Le pointeur permet ensuite à la voiture d’utiliser double Voiture::getPuissance() const return moteur->getPuissance(); Qui détruit l’objet ? Dans cette situation, la voiture utilise le moteur sans être responsable de sa destruction. Elle ne doit Le moteur continuera à suivre les règles correspondant à sa propre durée de vie. La destruction de la voiture ne doit pas entraîner automatiquement la destruction de l’objet qu’elle utilise. Objet contenu directement Existence indépendante non dans notre exemple Durées de vie liées
Accueil
2026 – Partie II C++ Composition et relations entre classe Le pointeur ne définit pas à lui seul la relation La présence d’un pointeur ne suffit pas pour conclure qu’il s’agit d’une agrégation car une classe peut également posséder un objet créé dynamiquement et conserver son adresse dans un pointeur. Inversement, dans notre exemple d’agrégation, le pointeur désigne un objet dont l’existence est indépendante de celle de la voiture. C’est donc la relation de propriété et de durée de vie entre les objets, et non la seule syntaxe utilisée, qui permet de distinguer les situations. Utiliser une référence Une relation avec un objet externe peut également être représentée par une référence : Voiture(Moteur& moteurExterne); Le constructeur initialise alors la référence : Voiture::Voiture(Moteur& moteurExterne) : moteur(moteurExterne) La voiture utilise ainsi directement un moteur existant sans en devenir automatiquement propriétaire. • La composition représente une relation forte dans laquelle un objet fait partie de l’objet • L’agrégation représente une relation plus faible : un objet peut utiliser un autre objet dont l’existence est indépendante. • Un objet utilisé par agrégation n’est pas nécessairement détruit lorsque l’objet qui l’utilise • Un pointeur classique ne permet pas, à lui seul, de savoir qui est propriétaire de l’objet • La présence d’un pointeur ne signifie donc pas automatiquement qu’il s’agit d’une agrégation. • Une référence peut également permettre à une classe d’utiliser un objet existant. • Lorsqu’un objet conserve un pointeur ou une référence vers un objet externe, il faut s’assurer que cet objet externe existe encore lorsqu’il est utilisé.
Accueil
2026 – Partie II C++ Composition et relations entre classe Comparaison des relations entre classes Nous avons maintenant rencontré plusieurs manières d’établir une relation entre des classes. Ces relations peuvent parfois conduire à des programmes dont la structure semble similaire, mais elles expriment des significations très différentes. Nous allons comparer les trois relations étudiées jusqu’ici : • la composition ; • l’agrégation. L’héritage : « est un » L’héritage établit une relation dans laquelle une classe dérivée représente une forme particulière de Nous avons par exemple utilisé : class Carre : public Forme Cette relation peut être exprimée par : Un carré est une forme. Un objet Carre peut donc être considéré comme un objet Forme. Cette propriété constitue notamment la base du polymorphisme étudié précédemment. La composition : « possède un » La composition établit une relation dans laquelle un objet contient directement un autre objet comme l’un de ses membres. Notre classe Voiture contient actuellement une batterie : Nous pouvons exprimer cette relation par : Une voiture possède une batterie. L’objet Batterie fait directement partie de l’objet Voiture. La durée de vie de cet objet membre est directement liée à celle de la voiture. Lorsque la voiture est construite, sa batterie est construite. Lorsque la voiture est détruite, sa batterie est automatiquement détruite.
Accueil
2026 – Partie II C++ Composition et relations entre classe L’agrégation : utiliser un objet indépendant Notre voiture utilise également un moteur qui existe indépendamment d’elle. Elle conserve pour cela son adresse : Le pointeur moteur permet à la voiture d’accéder à un objet Moteur existant ailleurs. La voiture ne contient donc pas directement cet objet et n’est pas, dans notre modèle, responsable de sa destruction. L’objet moteurExterne possède sa propre durée de vie. La voiture peut l’utiliser, mais sa destruction n’entraîne pas la destruction de ce moteur. Nous avons donc une relation plus faible que la composition : les deux objets restent indépendants du point de vue de leur durée de vie. Plusieurs relations dans une même classe Une même classe peut naturellement entretenir des relations différentes avec d’autres classes. Notre classe Voiture en fournit maintenant un exemple : Le membre batterie représente une composition. Le membre moteur permet ici une agrégation avec un objet Moteur existant indépendamment de la voiture.
Accueil
2026 – Partie II C++ Composition et relations entre classe La relation entre deux classes ne dépend donc pas uniquement du fait qu’elles apparaissent ensemble dans un programme. Elle dépend de la signification que nous donnons à cette relation, notamment en matière de substitution, de propriété et de durée de vie. Trois questions pour identifier la relation Lors de la conception de classes, quelques questions simples permettent souvent d’identifier la relation 1. L’objet peut-il réellement être considéré comme une forme particulière d’un autre objet ? EST UN −→ héritage 2. L’objet fait-il directement partie d’un autre objet, avec une durée de vie qui lui est naturellement POSSÈDE UN −→ composition 3. L’objet utilise-t-il un autre objet qui peut exister indépendamment de lui ? UTILISE UN OBJET INDÉPENDANT −→ agrégation • L’héritage exprime généralement une relation « est un ». • La composition exprime généralement une relation « possède un » dans laquelle l’objet membre fait directement partie de l’objet qui le contient. • L’agrégation représente une relation plus faible avec un objet qui peut exister indépendamment. • La composition lie naturellement la durée de vie de l’objet membre à celle de l’objet qui • Dans une agrégation, l’objet utilisé possède une durée de vie indépendante et l’objet qui l’utilise n’est pas nécessairement responsable de sa destruction. • Une même classe peut simultanément participer à plusieurs types de relations avec La programmation orientée objet ne consiste pas uniquement à créer des classes indépendantes. Dans un programme réel, les objets collaborent, se contiennent, utilisent d’autres objets ou appartiennent à des familles de types reliées par héritage. Dans ce chapitre, nous avons étudié plusieurs manières d’établir ces relations et, en particulier, la composition et l’agrégation. La composition permet de construire un objet complexe à partir d’objets plus simples. Dans notre premier modèle, une voiture contenait directement un moteur et une batterie :
Accueil
2026 – Partie II C++ Composition et relations entre classe Les objets membres font alors réellement partie de l’objet Voiture. Leur durée de vie est directement liée à celle de la voiture. Cette organisation possède des conséquences précises sur la construction des objets. Les objets membres sont construits avant l’exécution du corps du constructeur de l’objet qui les Lorsqu’une liste d’initialisation est utilisée : : moteur(puissance), elle indique comment construire chacun des objets membres. L’ordre réel de construction est toutefois déterminé par l’ordre de déclaration des membres dans la classe et non par leur ordre dans la liste d’initialisation. Lors de la destruction, les objets membres sont automatiquement détruits dans l’ordre inverse de leur construction. La composition permet également de répartir les responsabilités entre plusieurs classes. Une voiture n’a pas besoin de connaître la représentation interne de son moteur ou de sa batterie. Elle peut déléguer certaines opérations aux objets qu’elle contient : double Voiture::getCapaciteBatterie() const return batterie.getCapacite(); Chaque classe peut ainsi conserver une responsabilité clairement définie. Nous avons ensuite rencontré une relation plus faible : l’agrégation. Dans notre modèle final, la voiture contient directement sa batterie mais utilise un moteur existant Nous avons ainsi simultanément : Voiture – Batterie
Accueil
2026 – Partie II C++ Composition et relations entre classe L’utilisation d’un pointeur nous a également montré qu’une adresse ne contient aucune information indiquant qui est propriétaire de l’objet pointé. indique seulement où se trouve un objet de type Moteur. Il ne permet pas, à lui seul, de déterminer qui est responsable de la durée de vie et de la destruction de cet objet. Cette question de propriété deviendra particulièrement importante lorsque nous étudierons les mécanismes modernes de gestion Nous disposons maintenant de trois relations fondamentales permettant d’organiser nos classes : composition : POSSÈDE UN agrégation : UTILISE UN OBJET INDÉPENDANT Ces relations peuvent être combinées pour construire progressivement des architectures composées d’objets spécialisés qui collaborent entre eux. • Une classe peut contenir directement des objets appartenant à d’autres classes. • Dans une composition, les objets membres font partie de l’objet qui les contient et leurs durées de vie sont naturellement liées. • Les objets membres sont construits dans leur ordre de déclaration et détruits dans l’ordre • La liste d’initialisation permet de construire directement les objets membres avec les paramètres appropriés. • La composition permet à une classe de déléguer certaines responsabilités aux objets spécialisés qu’elle contient. • Dans une agrégation, un objet peut utiliser un autre objet possédant une existence • Un pointeur classique indique l’adresse d’un objet mais ne précise pas qui est propriétaire de cet objet ni qui doit assurer sa destruction. • Héritage, composition et agrégation représentent des relations différentes et peuvent être combinés dans une même architecture objet.
Accueil
P2-Chap-06- Composition et relations entre classe
Accueil
2026 – Partie II C++ Composition et relations entre classe
Accueil
Termes à ajouter au glossaire

Chapitre 7 - Conversions de types et transtypage

Chapitre 7 – Conversions de types et transtypage

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
7 Conversions de types et transtypage 7.1 Pourquoi convertir un type ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.1.1 Une conversion déjà familière . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.1.2 Toutes les conversions ne sont pas équivalentes . . . . . . . . . . . . . . . . . 7.1.3 Qui décide de la conversion ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.1.4 Pourquoi demander explicitement une conversion ? . . . . . . . . . . . . . . . 7.2 Les conversions implicites . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2.1 Conversion lors d’une affectation . . . . . . . . . . . . . . . . . . . . . . . . . 7.2.2 Conversion lors d’un appel de fonction . . . . . . . . . . . . . . . . . . . . . . 7.2.3 Expressions contenant plusieurs types . . . . . . . . . . . . . . . . . . . . . . . 7.2.4 Les promotions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2.5 Le type de destination n’agit pas rétroactivement . . . . . . . . . . . . . . . . 7.2.6 Conversion possible et conservation de l’information . . . . . . . . . . . . . . 7.3 Conversions et pertes d’information . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.3.1 Perte de la partie fractionnaire . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.3.2 Le compilateur peut accepter une perte d’information . . . . . . . . . . . . . 7.3.3 Conversion d’un entier vers un réel . . . . . . . . . . . . . . . . . . . . . . . . 7.3.4 Conversion entre types entiers . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.3.5 Types signés et non signés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.3.6 Les accolades et les conversions réductrices . . . . . . . . . . . . . . . . . . . 7.3.7 La taille du type n’est pas le seul critère . . . . . . . . . . . . . . . . . . . . . 7.4 Le transtypage : conversion explicite . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.4.1 Exprimer explicitement son intention . . . . . . . . . . . . . . . . . . . . . . . 7.4.2 Deux situations différentes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.4.3 Pourquoi utiliser un transtypage ? . . . . . . . . . . . . . . . . . . . . . . . . . 7.4.4 Modifier le type avant une opération . . . . . . . . . . . . . . . . . . . . . . . 7.4.5 Le type de la variable originale ne change pas . . . . . . . . . . . . . . . . . . 7.4.6 Un transtypage n’autorise pas n’importe quelle opération . . . . . . . . . . . 7.4.7 Plusieurs formes de transtypage . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5 Le transtypage de style C . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5.1 Conversion de double vers int . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5.2 Le transtypage porte sur une expression . . . . . . . . . . . . . . . . . . . . . 7.5.3 Transtyper avant une opération . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5.4 Une syntaxe héritée du langage C . . . . . . . . . . . . . . . . . . . . . . . . . 7.5.5 Une syntaxe peu descriptive . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5.6 Une syntaxe toujours valide . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.6 static_cast . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.6.1 Une première conversion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.6.2 Une intention clairement exprimée . . . . . . . . . . . . . . . . . . . . . . . . 7.6.3 Pourquoi le mot static ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.6.4 Modifier le type utilisé dans une expression . . . . . . . . . . . . . . . . . . . 7.6.5 static_cast n’empêche pas les pertes d’information . . . . . . . . . . . . . 1 3 3 3 4 5 5 6 6 6 7 7 8 8 9 9 10 10 10 10 11 11 12 12 13 13 13 14 14 14 15 15 16 16 17 17 18 19 19 19 19 20 20 2026 – Partie II C++ Conversions de types et transtypage 7.6.6 Une conversion soumise à des règles précises . . . . . . . . . . . . . . . . . . 7.6.7 Une conversion visible dans le programme . . . . . . . . . . . . . . . . . . . . 7.7 dynamic_cast . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.7.1 Une hiérarchie polymorphe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.7.2 Type du pointeur et type réel de l’objet . . . . . . . . . . . . . . . . . . . . . 7.7.3 Vérifier le type réel de l’objet . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.7.4 Échec du transtypage d’un pointeur . . . . . . . . . . . . . . . . . . . . . . . . 7.7.5 Pourquoi le mot dynamic ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.7.6 Remonter ou redescendre dans une hiérarchie . . . . . . . . . . . . . . . . . . 7.7.7 static_cast ou dynamic_cast ? . . . . . . . . . . . . . . . . . . . . . . . . . 7.7.8 Le cas des références . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.7.9 Une classe de base polymorphe . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8 const_cast . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8.0.1 L’objet et la manière d’y accéder . . . . . . . . . . . . . . . . . . . 7.8.1 Retirer une qualification const . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8.2 Un objet réellement constant . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8.3 const_cast et les références . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8.4 Ajouter la qualification const . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8.5 Pourquoi utiliser const_cast ? . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8.6 La qualification volatile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9 reinterpret_cast . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9.1 Une adresse et un type . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9.2 Réinterpréter un pointeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9.3 Le transtypage ne garantit pas un accès valide . . . . . . . . . . . . . . . . . 7.9.3.1 Convertir ou réinterpréter . . . . . . . . . . . . . . . . . . . . . . . . 7.9.4 Convertir un pointeur en valeur entière . . . . . . . . . . . . . . . . . . . . . . 7.9.5 Aucun nouvel objet n’est créé . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9.6 Pourquoi utiliser reinterpret_cast ? . . . . . . . . . . . . . . . . . . . . . . 7.9.7 Ne pas confondre réinterprétation et examen des bits . . . . . . . . . . . . . 7.10 Comparaison des quatre casts C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10.1 Une syntaxe commune . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10.2 static_cast : effectuer une conversion connue . . . . . . . . . . . . . . . . . 7.10.3 dynamic_cast : vérifier un type dans une hiérarchie polymorphe . . . . . . . 7.10.4 const_cast : modifier une qualification . . . . . . . . . . . . . . . . . . . . . 7.10.4.1 reinterpret_cast : effectuer une conversion de bas niveau . . . 7.10.4.2 Tableau comparatif . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10.5 Quatre opérateurs, quatre questions différentes . . . . . . . . . . . . . . . . . 7.10.6 Et le transtypage de style C ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10.7 Des responsabilités différentes . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10.8 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23/08/2026
Accueil
20 21 22 22 23 23 24 24 25 25 26 26 27 27 28 28 29 29 30 30 31 32 32 32 33 33 34 34 34 36 36 37 37 37 37 38 38 38 39 39 2/42 Chapitre 7 Conversions de types et transtypage 7.1 Pourquoi convertir un type ? Le C++ est un langage fortement typé. Lorsque nous déclarons une variable, nous indiquons le type des données qu’elle peut contenir : 1 2 3 int nombre = 5; double mesure = 3.7; char lettre = ’A’; Ces types ne sont pas interchangeables. Un int représente un nombre entier, un double permet de représenter un nombre réel avec une précision limitée et un char représente notamment un caractère. Il arrive cependant très fréquemment qu’une valeur d’un certain type doive être utilisée dans un contexte où un autre type est attendu. Une conversion de type devient alors nécessaire. 7.1.1 Une conversion déjà familière Considérons : 1 2 int n = 5; double x = n; La variable n est de type int, tandis que x est de type double. La valeur contenue dans n doit donc être représentée sous une forme compatible avec le type de x. Le compilateur effectue ici automatiquement une conversion : int −→ double Nous pouvons nous représenter l’opération ainsi : 5 −→ 5.0 Aucune instruction particulière n’a été demandée au compilateur. Cette conversion est dite implicite. Dans cette situation, la conversion est possible parce qu’une valeur entière telle que 5 peut être représentée comme une valeur de type double. 3 2026 – Partie II C++ Conversions de types et transtypage Le C++ définit de nombreuses conversions entre ses types fondamentaux. Elles permettent notamment d’utiliser ensemble des valeurs dont les types ne sont pas strictement identiques. Dans l’expression : 1 2 3 4 int n = 5; double x = 2.5; double resultat = n + x; les deux opérandes de l’addition ne possèdent pas le même type. Le C++ doit déterminer comment effectuer cette opération. Dans ce cas, la valeur entière est convertie vers un type permettant l’opération avec le double. Nous pouvons schématiser : n : int ↓ double x : double L’addition peut alors être effectuée avec des opérandes compatibles. 7.1.2 Toutes les conversions ne sont pas équivalentes Considérons maintenant la situation inverse : 1 2 double x = 8.7; int n = x; Le compilateur doit cette fois convertir une valeur de type double vers le type int. Or un int ne permet pas de représenter la partie fractionnaire de la valeur. Après conversion, nous obtenons : 8.7 −→ 8 Une partie de l’information a donc été perdue. Lors d’une conversion d’un nombre réel vers un type entier, la partie fractionnaire est supprimée. Il ne s’agit pas d’un arrondi. Par exemple : 8.9 −→ 8 et −8.9 −→ −8. La conversion vers l’entier effectue ici une troncature vers zéro. 23/08/2026
Accueil
4/42 2026 – Partie II C++ 7.1.3 Conversions de types et transtypage Qui décide de la conversion ? Dans les exemples précédents, les conversions ont été déclenchées automatiquement par les règles du langage. Le programmeur n’a écrit aucune instruction particulière pour demander : « transforme cette valeur en double » ou : « transforme cette valeur en int ». On parle alors de conversion implicite. Il existe cependant des situations dans lesquelles le programmeur souhaite indiquer explicitement qu’une valeur doit être considérée ou convertie selon un autre type. Nous parlerons alors de conversion explicite ou de transtypage. conversion implicite : décidée automatiquement selon les règles du langage conversion explicite : demandée explicitement par le programmeur 7.1.4 Pourquoi demander explicitement une conversion ? Une conversion explicite peut être utile lorsque le programmeur souhaite, par exemple : • utiliser ensemble des valeurs dont les types sont différents ; • imposer le type utilisé dans une opération ; • convertir volontairement une valeur réelle en valeur entière ; • appeler une fonction exigeant un type particulier ; • rendre clairement visible dans le programme qu’un changement de type est intentionnel. Une conversion explicite ne doit cependant pas être considérée comme un moyen de contourner le système de types. Elle exprime au contraire une décision du programmeur dont celui-ci doit comprendre les conséquences. À retenir • Le C++ est un langage fortement typé, mais il permet certaines conversions entre types. • Une conversion de type intervient lorsqu’une valeur d’un type doit être utilisée dans un contexte nécessitant un autre type. • Certaines conversions sont effectuées automatiquement par le compilateur : ce sont les conversions implicites. • Une conversion peut modifier la représentation d’une valeur et parfois entraîner une perte d’information. • La conversion d’une valeur réelle vers un type entier supprime sa partie fractionnaire ; elle n’effectue pas un arrondi. • Une conversion peut également être demandée explicitement par le programmeur. On parle alors de conversion explicite ou de transtypage. • Demander explicitement une conversion suppose d’en comprendre les conséquences. 23/08/2026
Accueil
5/42 2026 – Partie II C++ 7.2 Conversions de types et transtypage Les conversions implicites Une conversion est dite implicite lorsqu’elle est effectuée automatiquement par le compilateur, sans qu’une conversion soit explicitement demandée dans le programme. Nous en avons déjà rencontré un exemple : 1 2 int entier = 5; double reel = entier; La valeur contenue dans entier est de type int, alors que la variable reel est de type double. Le compilateur effectue automatiquement la conversion nécessaire. 7.2.1 Conversion lors d’une affectation Une conversion implicite apparaît fréquemment lorsqu’une valeur est affectée à une variable d’un autre type. Considérons : 1 2 int entier = 12; double reel = entier; Le type de la destination est connu : 1 double reel Le compilateur convertit donc la valeur de entier en double avant de l’affecter à reel. Nous pouvons représenter conceptuellement cette opération par : 12int −→ 12.0double . La conversion inverse est également possible : 1 2 double reel = 12.8; int entier = reel; Cette fois, la valeur doit être convertie de double vers int : 12.8double −→ 12int . La conversion est toujours implicite, mais elle entraîne maintenant une perte d’information. 7.2.2 Conversion lors d’un appel de fonction Une conversion implicite peut également avoir lieu lorsqu’un argument ne possède pas exactement le type attendu par un paramètre. Considérons : void afficher(double valeur) { 3 std::cout << valeur << std::endl; 4 } 1 2 Nous pouvons appeler cette fonction avec : 1 int n = 5; 2 3 afficher(n); 23/08/2026 https://cpp-iot-etc.ch 6/42 2026 - Partie II C++ Conversions de types et transtypage Le paramètre valeur est de type double, alors que l’argument n est de type int. Le compilateur effectue automatiquement la conversion : int −→ double. 7.2.3 Expressions contenant plusieurs types Les conversions implicites interviennent également dans les expressions arithmétiques. Considérons : 1 2 3 int entier = 5; double reel = 2.5; 4 double resultat = entier + reel; L’opérateur + reçoit ici deux opérandes de types différents. Le C++ applique ses règles de conversions arithmétiques afin de déterminer un type commun approprié pour effectuer l’opération. Dans cet exemple, la valeur entière est convertie en double : 5int −→ 5.0double . L’addition devient alors conceptuellement : 5.0double + 2.5double = 7.5double . 7.2.4 Les promotions Certaines conversions implicites portent plus précisément le nom de promotions. Une promotion convertit notamment certains petits types entiers vers un type entier permettant d’effectuer les opérations arithmétiques usuelles. Considérons par exemple : 1 2 char lettre = ’A’; 3 int code = lettre; Dans les jeux de caractères usuels compatibles avec ASCII, le caractère ’A’ correspond à la valeur numérique 65. La valeur peut donc être utilisée sous forme entière : 1 std::cout << code << std::endl; qui affichera alors : 65 Cette expérience montre que le contexte dans lequel une valeur est utilisée est important. Dans : 1 std::cout << lettre; 23/08/2026 https://cpp-iot-etc.ch 7/42 2026 - Partie II C++ Conversions de types et transtypage la valeur est traitée comme un caractère. Mais dans : 1 lettre + 1 une opération arithmétique est demandée. Le char subit alors une promotion entière avant l’addition. 7.2.5 Le type de destination n’agit pas rétroactivement Considérons : 1 2 3 int a = 5; int b = 2; 4 double resultat = a / b; Les deux opérandes de la division sont de type int. La division effectuée est donc une division entière : 5/2 = 2. Ce n’est qu’ensuite que le résultat est converti en double pour être affecté à resultat : 2int −→ 2.0double . La variable resultat contient donc 2.0, et non 2.5. Si l’un des opérandes est déjà de type double : 1 double resultat = a / 2.0; les règles de conversion conduisent ici à effectuer la division avec des valeurs de type double. Nous obtenons alors : 5.0/2.0 = 2.5. 7.2.6 Conversion possible et conservation de l’information Le fait que le compilateur puisse effectuer automatiquement une conversion ne signifie pas que celleci conserve nécessairement toute l’information. Par exemple : 1 2 double x = 3.14159; int n = x; est une conversion autorisée, mais la partie fractionnaire est perdue. De manière générale, il faut donc distinguer deux questions : 1. la conversion est-elle autorisée par le langage ? 2. la valeur obtenue conserve-t-elle l’information qui nous intéresse ? Ces deux questions ne sont pas équivalentes. 23/08/2026 https://cpp-iot-etc.ch 8/42 2026 - Partie II C++ Conversions de types et transtypage À retenir • Une conversion implicite est effectuée automatiquement selon les règles du langage, sans transtypage écrit par le programmeur. • Une conversion implicite peut intervenir lors d’une affectation, d’un appel de fonction ou de l’évaluation d’une expression. • Lorsque plusieurs types numériques interviennent dans une expression, le C++ applique des règles de conversion afin de déterminer comment effectuer l’opération. • Certaines conversions implicites sont appelées des promotions. • Le type de la variable recevant le résultat ne modifie pas rétroactivement les opérations déjà effectuées. • Dans : 1 double resultat = 5 / 2; la division entière produit d’abord 2, qui est ensuite converti en double. • Une conversion implicite autorisée peut néanmoins entraîner une perte d’information. 7.3 Conversions et pertes d’information Une conversion entre deux types numériques ne garantit pas que toute l’information contenue dans la valeur initiale puisse être conservée. Certains types permettent de représenter des ensembles de valeurs très différents. Lorsqu’une valeur est convertie vers un type incapable de représenter exactement cette valeur, une partie de l’information peut être perdue. Il est donc essentiel de distinguer : conversion autorisée et conversion sans perte d’information. Ces deux notions ne sont pas équivalentes. 7.3.1 Perte de la partie fractionnaire Considérons : 1 2 double x = 8.7; int n = x; La variable x contient une valeur possédant une partie fractionnaire. Le type int ne permet pas de conserver cette partie. Lors de la conversion : 8.7double −→ 8int la partie fractionnaire est supprimée. 23/08/2026 https://cpp-iot-etc.ch 9/42 2026 - Partie II C++ Conversions de types et transtypage Cette conversion n’effectue pas un arrondi au nombre entier le plus proche. Par exemple : 8.9 −→ 8 et −8.9 −→ −8. La partie fractionnaire est supprimée : la conversion s’effectue donc vers zéro. 7.3.2 Le compilateur peut accepter une perte d’information L’écriture : 1 2 double x = 8.7; int n = x; est autorisée par le langage. Cela ne signifie pas que la conversion préserve la valeur initiale. Le compilateur applique les règles du langage, mais il ne peut pas déterminer si la perte de la partie fractionnaire correspond réellement à l’intention du programmeur. 7.3.3 Conversion d’un entier vers un réel Une conversion de int vers double paraît généralement moins problématique : 1 2 int n = 90; double x = n; La valeur 90 peut être représentée exactement sous forme de double. La conversion conserve donc ici la valeur : 90int −→ 90.0double . Il faut cependant éviter d’en déduire que toute conversion d’un type entier vers un type à virgule flottante est nécessairement exacte. Les types à virgule flottante possèdent une précision finie. Certaines valeurs entières suffisamment grandes peuvent donc ne pas être représentables exactement dans le type de destination. 7.3.4 Conversion entre types entiers Les différents types entiers ne possèdent pas nécessairement la même capacité de représentation. Lorsqu’une valeur est convertie vers un type dont l’ensemble des valeurs représentables est plus restreint, la valeur initiale peut ne pas être représentable dans le type de destination. Par exemple, une conversion vers un type entier plus petit doit être considérée avec attention. En cas de nécessité on peut utiliser numeric_limits 7.3.5 Types signés et non signés Une attention particulière est également nécessaire lors des conversions entre types entiers signés et non signés. Un type signé peut représenter des valeurs négatives : 1 int n = -5; alors qu’un type non signé tel que : 1 unsigned int n; 23/08/2026
Accueil
10/42 2026 – Partie II C++ Conversions de types et transtypage ne représente pas de valeurs négatives. Une conversion entre ces catégories peut donc produire une valeur très différente de celle que le programmeur aurait intuitivement attendue. 7.3.6 Les accolades et les conversions réductrices Le C++ moderne fournit une forme d’initialisation qui permet d’interdire certaines conversions susceptibles de perdre de l’information. Comparons : int a = 8.7; int b(8.7); 3 int c{8.7}; 1 2 Les deux premières initialisations autorisent la conversion de double vers int. Nous obtenons alors : a =8 et b = 8. La troisième écriture est différente : 1 int c{8.7}; Elle provoque une erreur de compilation. L’initialisation par accolades interdit certaines conversions dites réductrices, ou narrowing conversions. Dans notre exemple, convertir 8.7 en int supprimerait la partie fractionnaire. Le compilateur refuse donc cette initialisation. L’écriture avec accolades ne constitue pas une nouvelle manière de convertir la valeur. Elle impose au contraire des règles plus strictes lors de l’initialisation et permet ainsi de détecter certaines conversions potentiellement dangereuses dès la compilation. Fig. 7.1 – 7.3.7 La taille du type n’est pas le seul critère Il serait tentant de décider si une conversion est sûre uniquement en comparant les valeurs retournées par sizeof. Ce raisonnement est insuffisant. Deux types peuvent utiliser le même nombre d’octets tout en représentant des ensembles de valeurs très différents. Par exemple, un entier et un nombre à virgule flottante n’utilisent pas leurs bits de la même manière. Pour étudier une conversion, il faut donc considérer non seulement la taille des types, mais surtout les valeurs et la précision qu’ils peuvent représenter. Nous pouvons maintenant distinguer trois situations : 23/08/2026
Accueil
11/42 2026 – Partie II C++ Conversions de types et transtypage 1. la valeur peut être représentée dans le nouveau type sans perte pertinente ; 2. la conversion est autorisée, mais une partie de l’information est perdue ; 3. la forme d’initialisation utilisée interdit la conversion et provoque une erreur de compilation. À retenir • Une conversion de type peut entraîner une perte d’information. • Lors d’une conversion d’un nombre à virgule flottante vers un type entier, la partie fractionnaire est supprimée ; il ne s’agit pas d’un arrondi. • Une conversion implicite peut être autorisée par le langage tout en produisant une valeur différente de celle que le programmeur souhaitait. • Les conversions entre types entiers de capacités différentes ainsi qu’entre types signés et non signés doivent être examinées avec attention. • Les types à virgule flottante possèdent une précision finie : une conversion numérique n’est donc pas nécessairement exacte. • L’initialisation par accolades interdit certaines conversions réductrices : 1 int n{8.7}; // erreur • Une conversion autorisée n’est pas nécessairement une conversion sans perte d’information. 7.4 Le transtypage : conversion explicite Jusqu’à présent, nous avons principalement rencontré des conversions effectuées automatiquement par le compilateur. Par exemple : 1 2 int entier = 5; double reel = entier; Le programmeur ne demande explicitement aucune conversion. Le compilateur constate que la valeur de type int doit être utilisée pour initialiser une variable de type double et applique la conversion prévue par le langage. Il s’agit d’une conversion implicite. 7.4.1 Exprimer explicitement son intention Considérons maintenant : 1 2 double mesure = 8.7; int valeur = mesure; Le compilateur accepte cette conversion. La partie fractionnaire est supprimée et nous obtenons : 8.7double −→ 8int . Cette écriture ne permet cependant pas de savoir immédiatement si cette perte d’information est réellement voulue par le programmeur ou si elle résulte d’une erreur. 23/08/2026
Accueil
12/42 2026 – Partie II C++ Conversions de types et transtypage Le compilateur peut autoriser une opération qui n’est pas celle que nous voulions. Le programmeur peut donc souhaiter indiquer explicitement qu’il demande une conversion et qu’il en accepte les conséquences. On parle alors de conversion explicite ou de transtypage. 7.4.2 Deux situations différentes Nous devons maintenant distinguer clairement deux mécanismes. Dans une conversion implicite, le programmeur fournit des valeurs de types différents et laisse le compilateur appliquer les règles de conversion du langage. Dans une conversion explicite, le programmeur indique dans le code le type vers lequel il souhaite effectuer la conversion. Nous pouvons résumer : conversion implicite : le compilateur applique automatiquement une conversion conversion explicite : le programmeur demande explicitement une conversion 7.4.3 Pourquoi utiliser un transtypage ? Le transtypage peut remplir plusieurs fonctions. Il permet tout d’abord de rendre visible l’intention du programmeur. Lorsqu’une conversion susceptible de modifier une valeur est écrite explicitement, le lecteur du programme sait immédiatement qu’elle a été demandée volontairement. Le transtypage peut également être nécessaire pour imposer le type utilisé dans une expression ou pour fournir à une fonction un argument du type attendu. 7.4.4 Modifier le type avant une opération Le moment auquel une conversion intervient peut modifier le résultat d’un calcul. Nous avons vu que : 1 2 3 4 int a = 5; int b = 2; double resultat = a / b; effectue d’abord une division entière. Nous obtenons donc : 5/2 = 2 avant que le résultat soit converti en double. Si nous demandons au contraire que l’un des opérandes soit converti en double avant la division, la nature de l’opération change : 5.0/2 = 2.5. Un transtypage peut donc servir non seulement à déterminer le type d’une valeur obtenue, mais également à contrôler le type avec lequel une opération sera effectuée. 23/08/2026
Accueil
13/42 2026 – Partie II C++ 7.4.5 Conversions de types et transtypage Le type de la variable originale ne change pas Lorsqu’une valeur est explicitement convertie vers un autre type, cela ne signifie pas que le type de la variable dont elle provient est modifié. Considérons une variable : 1 int a = 5; Si nous utilisons la valeur de a après l’avoir explicitement convertie en double, la variable a reste elle-même de type int. La conversion produit une valeur adaptée au contexte dans lequel elle est utilisée mais ne transforme pas la déclaration originale : 1 int a en une déclaration de type double. 7.4.6 Un transtypage n’autorise pas n’importe quelle opération Demander explicitement une conversion ne signifie pas que le compilateur acceptera nécessairement cette conversion. Les conversions possibles dépendent des types concernés et du mécanisme de transtypage utilisé. Le transtypage ne constitue donc pas une instruction signifiant au compilateur : « transforme cette valeur coûte que coûte ». Il constitue une demande explicite de conversion qui reste soumise aux règles du langage. 7.4.7 Plusieurs formes de transtypage Le C++ possède plusieurs mécanismes permettant d’exprimer explicitement une conversion. Nous rencontrerons notamment : 1 (int)valeur qui correspond à une syntaxe héritée du langage C, ainsi que les mécanismes spécifiques au C++ : 1 2 3 4 static_cast dynamic_cast const_cast reinterpret_cast Ces différents transtypages n’ont pas tous le même rôle. Le choix du mécanisme utilisé fournit donc également une information sur la nature de la conversion demandée. Nous pouvons déjà retenir leur rôle général : 23/08/2026 static_cast conversions ordinaires connues à la compilation dynamic_cast conversions liées à certaines hiérarchies de classes const_cast modification des qualifications const ou volatile reinterpret_cast réinterprétation de bas niveau
Accueil
14/42 2026 – Partie II C++ Conversions de types et transtypage Nous étudierons séparément ces mécanismes afin de comprendre pourquoi le C++ moderne préfère distinguer explicitement plusieurs catégories de transtypages. À retenir • Une conversion explicite est une conversion demandée volontairement dans le code par le programmeur. On parle également de transtypage. • Rendre une conversion explicite permet notamment d’indiquer clairement qu’un changement de type est intentionnel. • Un transtypage peut modifier le type utilisé pour effectuer une opération et donc modifier le résultat de cette opération. • Convertir la valeur d’une variable ne change pas le type de la variable originale. • Un transtypage ne permet pas d’effectuer arbitrairement n’importe quelle conversion : les règles du langage continuent de s’appliquer. • Le C++ possède plusieurs mécanismes de transtypage ayant des rôles différents. • La syntaxe héritée du C existe toujours, mais le C++ fournit également des opérateurs de transtypage spécifiques : static_cast, dynamic_cast, const_cast et reinterpret_cast. 7.5 Le transtypage de style C Le C++ conserve une syntaxe de transtypage héritée du langage C. Elle consiste à placer entre parenthèses le type souhaité devant l’expression à convertir. La forme générale est : 1 (type)expression Par exemple : 1 2 double x = 8.7; int n = (int)x; L’expression : 1 (int)x demande explicitement une conversion de la valeur de x vers le type int. 7.5.1 Conversion de double vers int Considérons : 1 2 double x = 8.7; int n = (int)x; La conversion est cette fois explicitement demandée par le programmeur. Nous obtenons : 8.7double −→ 8int . Comme lors de la conversion implicite étudiée précédemment, la partie fractionnaire est supprimée. 23/08/2026
Accueil
15/42 2026 – Partie II C++ Conversions de types et transtypage La présence du transtypage ne modifie donc pas la règle de conversion mais rend explicite la volonté de l’effectuer. 7.5.2 Le transtypage porte sur une expression Le transtypage ne change pas le type de la variable originale. Considérons : 1 2 3 double x = 8.7; int n = (int)x; La variable x reste de type double et conserve sa valeur. Le transtypage : 1 (int)x produit une valeur de type int à partir de la valeur de x. 7.5.3 Transtyper avant une opération Le transtypage peut être effectué avant une opération afin de modifier les types avec lesquels cette opération sera évaluée. Reprenons : 1 2 3 4 int a = 5; int b = 2; double resultat = a / b; La division est effectuée entre deux int : 5/2 = 2. Nous obtenons donc finalement : resultat = 2.0. Nous pouvons maintenant écrire : 1 double resultat = (double)a / b; Le transtypage : 1 (double)a produit une valeur de type double avant que la division soit effectuée, conceptuellement on a : 5int −→ 5.0double . L’expression devient alors : 5.0/2. Les règles de conversion arithmétique conduisent à effectuer la division avec des valeurs de type double et nous obtenons finalement : resultat = 2.5 Soit 23/08/2026
Accueil
16/42 2026 – Partie II C++ 1 2 3 4 5 6 7 8 Conversions de types et transtypage int a = 5; int b = 2; double x = a / b; double y = (double)a / b; std::cout << x << std::endl; std::cout << y << std::endl; dont la sortie est : 1 2 2 2.5 Dans : 1 (double)a les parenthèses entourant double appartiennent à la syntaxe du transtypage. Il ne faut donc pas confondre cette écriture avec l’utilisation habituelle des parenthèses pour contrôler l’ordre d’évaluation d’une expression. 7.5.4 Une syntaxe héritée du langage C La syntaxe : 1 (int)x provient du langage C. Le C++ ayant été conçu historiquement à partir du C, une grande quantité de code et de pratiques utilisant cette forme de transtypage existait déjà. Le C++ conserve donc cette syntaxe, qui reste valide. Nous pouvons ainsi rencontrer très fréquemment des expressions telles que : 1 2 3 (int)x (double)n (char)code dans des programmes C et C++. Le C++ permet également certaines conversions explicites sous une forme ressemblant à l’appel d’une fonction : 1 int n = int(x); Cette écriture ne signifie pas que int est une classe possédant un constructeur. int reste un type fondamental. Nous privilégierons cependant par la suite les opérateurs de transtypage spécifiques au C++ moderne. 7.5.5 Une syntaxe peu descriptive Le principal problème du transtypage de style C en C++ est qu’il exprime peu précisément la nature de la conversion demandée. Lorsque nous rencontrons : 23/08/2026 https://cpp-iot-etc.ch 17/42 2026 - Partie II C++ 1 Conversions de types et transtypage (int)x nous savons que le programmeur souhaite obtenir une valeur de type int, mais la syntaxe elle-même n’indique pas quelle catégorie de conversion est effectuée. Un autre avantage des opérateurs de transtypage du C++ est leur visibilité dans le code. Il est très facile de rechercher toutes les occurrences de : 1 static_cast dans un projet. La recherche systématique des transtypages de style C est beaucoup moins simple, car les parenthèses sont utilisées partout dans un programme C++. 7.5.6 Une syntaxe toujours valide Le transtypage de style C n’est pas interdit en C++. L’écriture : 1 int n = (int)x; est parfaitement valide lorsque la conversion demandée est autorisée. Nous la rencontrerons donc encore dans de nombreux programmes. En C++ moderne, on préfère cependant généralement utiliser les opérateurs de transtypage propres au C++, car ils expriment plus précisément l’intention du programmeur et permettent de mieux distinguer différentes catégories de conversions. À retenir • Le C++ conserve une syntaxe de transtypage héritée du langage C : 1 (type)expression • Par exemple : 1 int n = (int)x; demande explicitement une conversion de la valeur de x vers le type int. • Le transtypage agit sur une expression ; il ne change pas le type de la variable originale. • Un transtypage effectué avant une opération peut modifier le type avec lequel cette opération est évaluée et donc son résultat. • La syntaxe de style C reste parfaitement valide en C++. • Elle indique le type souhaité, mais exprime peu précisément la nature de la conversion effectuée. • Le C++ fournit des opérateurs de transtypage spécifiques qui permettent de distinguer différentes catégories de conversions. • Dans du code C++ moderne, ces opérateurs sont généralement préférés au transtypage de style C. 23/08/2026 https://cpp-iot-etc.ch 18/42 2026 - Partie II C++ 7.6 Conversions de types et transtypage static_cast Le C++ fournit plusieurs opérateurs de transtypage spécialisés. Le plus couramment utilisé pour les conversions ordinaires est : 1 static_cast Sa syntaxe générale est : 1 static_cast(expression) Le type placé entre les chevrons indique le type vers lequel nous souhaitons effectuer la conversion. 7.6.1 Une première conversion Considérons : 1 2 3 double x = 8.7; int n = static_cast(x); L’expression : 1 static_cast(x) demande explicitement la conversion de la valeur de x vers le type int. Nous obtenons : 8.7double −→ 8int . Comme précédemment, la partie fractionnaire est supprimée. 7.6.2 Une intention clairement exprimée L’écriture : 1 static_cast(x) est plus longue que : 1 (int)x mais cette longueur est volontaire. Le nom static_cast indique explicitement la catégorie de transtypage demandée. Le lecteur sait immédiatement qu’il s’agit d’une conversion que le compilateur peut vérifier selon les règles correspondant à static_cast. 7.6.3 Pourquoi le mot static ? Dans static_cast, le terme static indique que la validité de la conversion est déterminée lors de la compilation. Le compilateur connaît les types concernés et peut vérifier si la conversion demandée est autorisée par les règles de static_cast. Il n’est pas nécessaire d’effectuer une vérification dynamique du type de l’objet pendant l’exécution du programme. 23/08/2026
Accueil
19/42 2026 – Partie II C++ 7.6.4 Conversions de types et transtypage Modifier le type utilisé dans une expression Reprenons : 1 2 3 4 5 int a = 5; int b = 2; double resultat = static_cast(a) / b; L’expression : 1 static_cast(a) produit une valeur de type double avant que la division soit effectuée. Nous avons donc conceptuellement : 5int −→ 5.0double . La division est alors effectuée avec le type approprié : 5.0/2.0 = 2.5. Nous obtenons donc : resultat = 2.5 7.6.5 static_cast n’empêche pas les pertes d’information L’utilisation de static_cast ne garantit pas que la conversion conserve toute l’information. Par exemple : 1 2 3 double x = 8.7; int n = static_cast(x); est parfaitement valide. Nous obtenons toujours : 8.7 −→ 8. Le rôle de static_cast n’est donc pas d’empêcher toutes les conversions susceptibles de perdre de l’information. Il permet d’exprimer explicitement une conversion appartenant aux catégories qu’il autorise. 7.6.6 Une conversion soumise à des règles précises static_cast n’est pas un ordre permettant de transformer arbitrairement n’importe quel type en n’importe quel autre type. Le compilateur vérifie que la conversion demandée appartient aux conversions autorisées par static_cast. Si ce n’est pas le cas, la compilation échoue. 23/08/2026
Accueil
20/42 2026 – Partie II C++ 7.6.7 Conversions de types et transtypage Une conversion visible dans le programme L’écriture : 1 static_cast(a) est particulièrement visible dans le code. Cette propriété est utile lors de la lecture ou de l’analyse d’un programme : une recherche sur le mot 1 static_cast permet de retrouver directement les conversions de cette catégorie. Cette possibilité est beaucoup moins évidente avec les transtypages de style C, dont la syntaxe repose simplement sur des parenthèses. Caractéristique Style C C++ Syntaxe Conversion explicite Intention facilement visible Catégorie de conversion indiquée Recherche dans le code (int)x oui faible non difficile static_cast(x) oui forte oui facile Dans la suite de ce cours, lorsque nous aurons besoin d’une conversion appartenant à cette catégorie, nous privilégierons donc l’écriture : 1 static_cast(expression) plutôt que le transtypage de style C. 1 2 3 4 5 6 #include int main() { int a = 5; int b = 2; 7 8 9 10 11 12 double resultat1 = a / b; double resultat2 = (double)a / b; double resultat3 = static_cast(a) / b; std::cout << resultat1 << std::endl; 13 14 15 16 17 18 std::cout << resultat2 << std::endl; 19 20 21 22 23 24 25 26 std::cout << resultat3 << std::endl; return 0; } 23/08/2026 https://cpp-iot-etc.ch 21/42 2026 - Partie II C++ Conversions de types et transtypage La sortie est : 2 2.5 3 2.5 1 2 À retenir • La syntaxe générale de static_cast est : 1 static_cast(expression) • static_cast permet d’exprimer explicitement de nombreuses conversions vérifiables à la compilation. • Dans les conversions numériques simples : 1 (int)x et 1 static_cast(x) peuvent produire le même résultat. • static_cast exprime cependant plus précisément l’intention du programmeur et la catégorie de conversion demandée. • static_cast peut être utilisé avant une opération afin d’imposer le type avec lequel celle-ci sera effectuée. • Il ne garantit pas l’absence de perte d’information. • Il n’autorise pas n’importe quelle conversion : le compilateur vérifie si la conversion demandée est permise. • En C++ moderne, static_cast est généralement préféré au transtypage de style C pour les conversions auxquelles il s’applique. 7.7 dynamic_cast L’opérateur dynamic_cast est principalement utilisé lors de conversions entre types appartenant à une même hiérarchie de classes polymorphes. Contrairement à static_cast, il peut effectuer une vérification du type réel d’un objet pendant l’exécution du programme. Sa syntaxe générale est : 1 dynamic_cast(expression) 7.7.1 Une hiérarchie polymorphe Considérons une classe de base Forme et deux classes dérivées : 1 2 3 class Forme { public: 23/08/2026
Accueil
22/42 2026 – Partie II C++ 4 5 6 7 8 9 10 11 12 13 14 Conversions de types et transtypage virtual ~Forme() = default; }; class Carre : public Forme { }; class Cercle : public Forme { }; La présence d’au moins une fonction virtuelle rend la classe Forme polymorphe. Cette propriété permettra notamment à dynamic_cast de déterminer le type réel de certains objets pendant l’exécution. 7.7.2 Type du pointeur et type réel de l’objet Considérons : 1 2 3 Carre carre; Forme* forme = &carre; La variable forme est un pointeur de type : 1 Forme* mais elle contient l’adresse d’un objet dont le type réel est Carre. Nous devons donc distinguer : type du pointeur : Forme* type réel de l’objet : Carre 7.7.3 Vérifier le type réel de l’objet Nous pouvons tenter de retrouver un pointeur vers le type dérivé : 1 2 Carre* carre2 = dynamic_cast(forme); L’opérateur dynamic_cast vérifie si l’objet désigné par forme peut effectivement être considéré comme un Carre. Dans notre exemple, c’est le cas. Le transtypage réussit et carre2 contient l’adresse du même objet. Fig. 7.2 – Schéma. 23/08/2026
Accueil
23/42 2026 – Partie II C++ 7.7.4 Conversions de types et transtypage Échec du transtypage d’un pointeur Considérons maintenant : 1 2 3 4 5 6 Cercle cercle; Forme* forme = &cercle; Carre* carre = dynamic_cast(forme); Le pointeur forme désigne cette fois un objet dont le type réel est Cercle. Il n’est donc pas possible d’obtenir un Carre* désignant cet objet. Lorsque dynamic_cast échoue lors d’une conversion de pointeur, il retourne un pointeur nul. Nous avons donc : 1 carre == nullptr 7.7.5 Pourquoi le mot dynamic ? Le type réel de l’objet désigné par un pointeur de classe de base peut dépendre de ce qui se produit pendant l’exécution du programme. Dans : 1 Forme* forme; le pointeur pourrait, selon la situation, désigner un Carre, un Cercle ou un autre objet appartenant à la hiérarchie. dynamic_cast permet d’effectuer une vérification du type réel de l’objet pendant l’exécution. C’est cette vérification dynamique qui explique son nom. Exemple : 1 2 3 4 #include class Forme { 5 public: 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 virtual ~Forme() = default; }; class Carre : public Forme { }; class Cercle : public Forme { }; int main() { Carre carre; Cercle cercle; 23/08/2026
Accueil
24/42 2026 – Partie II C++ 22 23 24 25 26 Forme* forme1 = &carre; Forme* forme2 = &cercle; Carre* resultat1 = dynamic_cast(forme1); 27 28 29 30 31 32 Carre* resultat2 = dynamic_cast(forme2); if (resultat1 != nullptr) { std::cout << "forme1 est un Carre" << std::endl; } 33 34 35 36 37 38 if (resultat2 == nullptr) { std::cout << "forme2 n’est pas un Carre" << std::endl; } 39 40 41 42 43 44 45 46 47 Conversions de types et transtypage return 0; } La sortie est : 1 2 3 forme1 est un Carre forme2 n’est pas un Carre Program ended with exit code: 0 7.7.6 Remonter ou redescendre dans une hiérarchie Une conversion d’un type dérivé vers son type de base est souvent appelée upcast. Par exemple : 1 2 3 Carre carre; Forme* forme = &carre; Cette conversion est naturelle : un Carre est une Forme. Elle peut être effectuée implicitement. La conversion inverse est souvent appelée downcast. Elle est plus délicate. Un pointeur Forme* ne désigne pas nécessairement un Carre : il pourrait désigner un Cercle ou un autre type dérivé. Une vérification peut donc être nécessaire avant de considérer l’objet comme appartenant au type dérivé demandé. 7.7.7 static_cast ou dynamic_cast ? Dans certaines situations liées à l’héritage, un transtypage vers un type dérivé peut également être exprimé avec static_cast. 23/08/2026 https://cpp-iot-etc.ch 25/42 2026 - Partie II C++ Conversions de types et transtypage Cependant, static_cast n’effectue pas la vérification dynamique permettant de déterminer si l’objet désigné possède réellement le type dérivé demandé. Lorsque cette vérification est nécessaire, dynamic_cast constitue l’outil approprié. Fig. 7.3 – Schéma. 7.7.8 Le cas des références dynamic_cast peut également être utilisé avec des références. Contrairement à un pointeur, une référence valide ne possède pas de valeur nulle permettant de signaler simplement l’échec du transtypage. Si un dynamic_cast vers une référence échoue, une exception de type : 1 std::bad_cast est déclenchée. Nous reviendrons sur le mécanisme des exceptions à l’endroit approprié du cours. 7.7.9 Une classe de base polymorphe Pour utiliser dynamic_cast afin d’effectuer ce type de vérification dynamique dans une hiérarchie, la classe de base doit être polymorphe. Une classe est polymorphe lorsqu’elle possède au moins une fonction virtuelle. Dans notre exemple : 1 2 3 4 5 6 class Forme { public: virtual ~Forme() = default; }; le destructeur virtuel rend la classe Forme polymorphe. 23/08/2026 https://cpp-iot-etc.ch 26/42 2026 - Partie II C++ Conversions de types et transtypage À retenir • dynamic_cast est principalement utilisé pour certaines conversions dans des hiérarchies de classes polymorphes. • Il peut vérifier pendant l’exécution si l’objet désigné possède un type compatible avec le type demandé. • Un pointeur vers une classe de base peut désigner un objet appartenant à une classe dérivée. • Lorsqu’un dynamic_cast de pointeur échoue, le résultat est nullptr. • Une conversion d’un type dérivé vers un type de base est souvent appelée upcast et peut généralement être effectuée implicitement. • Une conversion d’un type de base vers un type dérivé est souvent appelée downcast. Elle peut nécessiter une vérification du type réel de l’objet. • Contrairement à static_cast, dynamic_cast peut effectuer cette vérification dynamique. • Pour les vérifications dynamiques étudiées ici, la classe de base doit être polymorphe, c’est-à-dire posséder au moins une fonction virtuelle. • Lorsqu’un dynamic_cast vers une référence échoue, une exception std::bad_cast est déclenchée. 7.8 const_cast L’opérateur const_cast permet de modifier certaines qualifications d’un type, principalement la qualification const. Sa syntaxe générale est : 1 const_cast(expression) Contrairement à static_cast, son objectif n’est pas de convertir une valeur numérique vers un autre type. Il agit sur les qualifications associées à l’accès à un objet, notamment par l’intermédiaire d’un pointeur ou d’une référence. 7.8.0.1 L’objet et la manière d’y accéder Considérons : 1 2 3 int a = 5; const int* p = &a; La variable a est de type int. Elle n’a donc pas été déclarée constante. Le pointeur p, en revanche, est de type : 1 const int* Il permet de lire la valeur de a, mais interdit de la modifier par son intermédiaire. Ainsi : 23/08/2026
Accueil
27/42 2026 – Partie II C++ Conversions de types et transtypage 1 2 std::cout << *p << std::endl; // autorise 3 *p = 10; // erreur La qualification const concerne ici l’accès fourni par p. 7.8.1 Retirer une qualification const Nous pouvons demander explicitement un accès non constant à l’objet : 1 2 int* q = const_cast(p); Le pointeur p possède le type : 1 const int* tandis que q possède le type : 1 int* Les deux pointeurs désignent cependant le même objet. Nous pouvons alors écrire : 1 *q = 10; Cette opération est valide dans cet exemple, car l’objet original a n’a jamais été déclaré const. Après cette instruction : a = 10. Le const_cast n’a donc pas transformé un objet constant en objet modifiable. Il a retiré la qualification const de l’accès utilisé pour atteindre un objet qui était lui-même modifiable. 7.8.2 Un objet réellement constant Considérons maintenant : 1 2 const int a = 5; 3 4 5 6 const int* p = &a; int* q = const_cast(p); Cette fois, l’objet original a été déclaré : 1 const int a = 5; Il s’agit donc réellement d’un objet constant. const_cast permet d’obtenir un pointeur dont le type ne contient plus la qualification const : 1 int* q mais cela ne rend pas pour autant l’objet original modifiable, si nous tentons : 1 *q = 10; 23/08/2026
Accueil
28/42 2026 – Partie II C++ Conversions de types et transtypage nous essayons de modifier un objet qui a réellement été créé const. Le programme présente alors un comportement indéfini (undefined behavior ). Il ne faut donc pas effectuer cette opération. Objet original Accès Modification après const_cast int a = 5; const int* possible const int a = 5; const int* comportement indéfini La question essentielle n’est donc pas seulement : « le pointeur contient-il const dans son type ? » mais également : « l’objet original a-t-il réellement été déclaré const ? » 7.8.3 const_cast et les références Le même principe s’applique aux références. Considérons : 1 2 3 4 int a = 5; 5 6 7 8 int& autreRef = const_cast(ref); const int& ref = a; autreRef = 20; La référence ref fournit initialement un accès constant à a. const_cast permet ici d’obtenir une référence non constante vers le même objet. Cette modification est valide parce que l’objet original a n’a pas été déclaré const. 7.8.4 Ajouter la qualification const const_cast peut également être utilisé dans des conversions ajoutant certaines qualifications. Cependant, lorsqu’il s’agit simplement d’obtenir un accès constant à partir d’un accès modifiable, aucun transtypage explicite n’est généralement nécessaire. Par exemple : 1 2 3 4 int a = 5; int* p = &a; const int* q = p; est directement accepté. L’ajout de const est ici naturel et ne nécessite donc pas l’emploi de const_cast. 23/08/2026
Accueil
29/42 2026 – Partie II C++ 7.8.5 Conversions de types et transtypage Pourquoi utiliser const_cast ? Dans du code C++ moderne correctement conçu, retirer une qualification const doit rester une opération exceptionnelle. const_cast peut cependant être nécessaire lors de l’interaction avec une interface ancienne dont la déclaration ne respecte pas correctement la constance des données. Supposons par exemple qu’une ancienne fonction soit déclarée : 1 void ancienneFonction(char* texte); alors que cette fonction ne modifie en réalité jamais les caractères désignés par son paramètre. Une interface moderne aurait probablement été déclarée : 1 void fonction(const char* texte); Dans certaines situations bien contrôlées, const_cast peut alors servir d’adaptateur entre ces deux conceptions. Une telle utilisation n’est sûre que si l’on sait que la fonction appelée ne tentera effectivement pas de modifier un objet qui doit rester constant. const_cast ne doit donc pas être utilisé simplement pour faire disparaître une erreur du compilateur liée à const. 7.8.6 La qualification volatile Malgré son nom, const_cast ne concerne pas uniquement const. Il permet également certaines modifications des qualifications volatile. Nous ne développerons pas ici les utilisations de volatile, qui apparaissent notamment dans certains contextes de programmation de bas niveau. Nous retiendrons pour l’instant que const_cast est l’opérateur C++ destiné à modifier les qualifications const et volatile. Illustration : 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #include int main() { int a = 5; const int* p = &a; std::cout << "a = " << a << std::endl; int* q = const_cast(p); *q = 10; std::cout << "a = " << a << std::endl; 23/08/2026 https://cpp-iot-etc.ch 30/42 2026 - Partie II C++ 24 25 26 27 28 std::cout << "*p = " << *p << std::endl; 29 30 31 32 33 34 std::cout << "*q = " << *q << std::endl; 35 Conversions de types et transtypage return 0; } La sortie est : 1 2 3 4 a = 5 a = 10 *p = 10 *q = 10 À retenir • const_cast permet de modifier certaines qualifications d’un type, principalement const . • Sa syntaxe générale est : 1 const_cast(expression) • Il faut distinguer la constance de l’objet lui-même de la constance de l’accès fourni par un pointeur ou une référence. • Si l’objet original n’est pas constant, il est possible de retirer const d’un accès puis de modifier cet objet. • Si l’objet original a réellement été déclaré const, retirer la qualification de l’accès ne rend pas l’objet modifiable. • Tenter ensuite de modifier un objet réellement constant conduit à un comportement indéfini. • const_cast fonctionne notamment avec les pointeurs et les références. • L’ajout de const ne nécessite généralement aucun transtypage explicite. • Retirer const doit rester exceptionnel et ne doit pas servir simplement à contourner une erreur signalée par le compilateur. • const_cast peut également intervenir sur la qualification volatile. 7.9 reinterpret_cast L’opérateur reinterpret_cast est destiné à certaines conversions de bas niveau. Sa syntaxe générale est : 23/08/2026
Accueil
31/42 2026 – Partie II C++ 1 Conversions de types et transtypage reinterpret_cast(expression) Contrairement à une conversion numérique telle que : 1 static_cast(8.7) reinterpret_cast n’a généralement pas pour objectif de transformer une valeur numérique en une valeur équivalente appartenant à un autre type mais permet notamment d’effectuer certaines conversions entre types de pointeurs ou entre pointeurs et types entiers capables de représenter une adresse. 7.9.1 Une adresse et un type Considérons : 1 2 int n = 90; int* p = &n; Le pointeur p contient l’adresse de n. Son type est : 1 int* Ce type ne décrit donc pas seulement une adresse. Il indique également au compilateur le type de l’objet auquel nous souhaitons accéder par l’intermédiaire de cette adresse. 7.9.2 Réinterpréter un pointeur Nous pouvons écrire : 1 2 3 int n = 90; int* p = &n; 4 5 double* q = reinterpret_cast(p); Le pointeur p possède le type : 1 int* tandis que q possède le type : 1 double* reinterpret_cast permet ici une conversion de bas niveau entre ces types de pointeurs. L’existence de q ne signifie cependant pas que l’objet n est devenu un double. L’objet stocké en mémoire reste un objet de type int. Le transtypage a modifié le type du pointeur utilisé pour considérer l’adresse ; il n’a pas transformé l’objet situé à cette adresse. 7.9.3 Le transtypage ne garantit pas un accès valide Le fait qu’un reinterpret_cast soit accepté par le compilateur ne signifie pas que toutes les utilisations ultérieures du résultat sont valides. Dans notre exemple : 1 2 double* q = reinterpret_cast(p); 23/08/2026
Accueil
32/42 2026 – Partie II C++ Conversions de types et transtypage l’objet situé à l’adresse considérée reste un int. Tenter d’y accéder comme s’il s’agissait réellement d’un objet double peut violer les règles du langage et conduire à un comportement indéfini. reinterpret_cast exige donc une connaissance précise de la représentation des objets et des règles d’accès à la mémoire. 7.9.3.1 Convertir ou réinterpréter Il faut distinguer fondamentalement une conversion de valeur d’une réinterprétation de bas niveau. Considérons : 1 2 3 int n = 90; double x = static_cast(n); Une nouvelle valeur de type double correspondant numériquement à la valeur de n est produite : 90int −→ 90.0double . En revanche, une expression telle que : 1 reinterpret_cast(p) ne convertit pas la valeur entière en valeur réelle. Elle effectue une conversion de bas niveau portant ici sur le type du pointeur. Fig. 7.4 – static_cast reinterpret_cast 7.9.4 vs. Convertir un pointeur en valeur entière reinterpret_cast peut notamment intervenir lors de certaines conversions entre pointeurs et types entiers. La bibliothèque standard définit, lorsqu’il est disponible, le type : 1 std::uintptr_t destiné à fournir un type entier non signé capable de contenir une valeur de pointeur convertie sous forme entière. Par exemple : 1 2 3 #include int n = 90; 23/08/2026
Accueil
33/42 2026 – Partie II C++ 4 5 6 Conversions de types et transtypage std::uintptr_t adresse = reinterpret_cast(&n); Et maintenant adresse est un entier, pas un pointeur. Dans les conditions prévues par le langage, une valeur de pointeur convertie vers un type entier suffisamment grand peut être reconvertie vers son type de pointeur original. Par exemple : 1 2 int* nouveauPointeur = reinterpret_cast(adresse); Nous pouvons alors vérifier : 1 2 3 4 std::cout << std::boolalpha << (nouveauPointeur == &n) << std::endl; 7.9.5 Aucun nouvel objet n’est créé Lorsqu’un pointeur est converti avec reinterpret_cast, aucun nouvel objet n’est créé par cette seule opération. Le transtypage ne copie pas l’objet et ne transforme pas automatiquement les octets présents en mémoire. Il produit une valeur du type demandé selon les conversions de bas niveau autorisées par cet opérateur. 7.9.6 Pourquoi utiliser reinterpret_cast ? Certaines opérations de programmation de bas niveau nécessitent de manipuler explicitement des représentations ou des adresses sous des formes différentes. reinterpret_cast fournit une syntaxe clairement identifiable pour certaines de ces conversions. Son utilisation signale immédiatement au lecteur que le programme effectue une opération de bas niveau dont la validité ne peut pas être déduite simplement d’une conversion ordinaire de valeurs. 7.9.7 Ne pas confondre réinterprétation et examen des bits Le nom reinterpret_cast pourrait laisser penser qu’il constitue l’outil général permettant de lire arbitrairement les bits d’un objet sous la forme d’un autre type. Cette interprétation serait dangereuse. Les règles C++ concernant le type réel des objets, leur alignement et les accès à leur représentation doivent toujours être respectées. Le C++ moderne fournit d’autres mécanismes lorsque l’objectif est spécifiquement d’examiner ou de transférer une représentation binaire. Exemple : 1 2 3 4 #include #include #include 23/08/2026
Accueil
34/42 2026 – Partie II C++ 5 6 7 8 9 int main() { int n = 90; int* p = &n; 10 11 12 13 14 15 std::uintptr_t adresse = reinterpret_cast(p); std::cout << "p = " << p << std::endl; 16 17 18 19 20 21 std::cout << "adresse en decimal = " << adresse << std::endl; 22 23 24 25 26 27 std::cout << "adresse en hexadecimal = 0x" << std::hex << adresse << std::endl; 28 29 30 31 32 33 int* q = reinterpret_cast(adresse); std::cout << std::boolalpha << "q == p : " << (q == p) << std::endl; 34 35 36 37 38 39 40 Conversions de types et transtypage return 0; } La sortie est : 1 2 3 4 p = 0x16fdfee64 adresse en decimal = 6171917924 adresse en hexadecimal = 0x16fdfee64 q == p : true 23/08/2026 https://cpp-iot-etc.ch 35/42 2026 - Partie II C++ Conversions de types et transtypage À retenir • reinterpret_cast est destiné à certaines conversions de bas niveau. • Sa syntaxe générale est : 1 reinterpret_cast(expression) • Il ne faut pas le confondre avec une conversion numérique effectuée par static_cast. • Une conversion entre types de pointeurs ne transforme pas l’objet situé à l’adresse considérée. • Le fait qu’un reinterpret_cast soit accepté par le compilateur ne garantit pas que le résultat puisse ensuite être déréférencé ou utilisé arbitrairement. • Un accès incompatible avec le type réel de l’objet peut conduire à un comportement indéfini. • reinterpret_cast peut notamment être utilisé pour certaines conversions entre pointeurs et types entiers capables de représenter une adresse. • Lorsqu’un transtypage de pointeur est effectué, aucun nouvel objet n’est créé par cette seule opération. • L’utilisation de reinterpret_cast doit rester limitée aux situations de bas niveau dans lesquelles la représentation et les contraintes des données sont précisément connues. 7.10 Comparaison des quatre casts C++ Le C++ moderne distingue quatre opérateurs principaux de transtypage : static_cast dynamic_cast const_cast 4 reinterpret_cast 1 2 3 Cette séparation n’est pas simplement syntaxique, chaque opérateur correspond à une catégorie particulière de transtypage et exprime donc une intention différente du programmeur. Le choix d’un opérateur de transtypage renseigne immédiatement le lecteur sur la nature de l’opération demandée. 7.10.1 Une syntaxe commune Les quatre opérateurs utilisent une syntaxe de même forme : 1 nom_du_cast(expression) Par exemple : 1 2 3 4 5 6 7 static_cast(a) dynamic_cast(forme) const_cast(p) reinterpret_cast(p) 23/08/2026
Accueil
36/42 2026 – Partie II C++ Conversions de types et transtypage Le type souhaité apparaît entre les chevrons et l’expression concernée entre les parenthèses. C’est donc principalement le nom de l’opérateur qui indique la nature du transtypage demandé. 7.10.2 static_cast : effectuer une conversion connue static_cast est utilisé pour de nombreuses conversions dont la validité peut être déterminée à la compilation. Nous l’avons notamment utilisé pour les conversions numériques : 1 double x = 8.7; 2 3 4 int n = static_cast(x); Nous obtenons : 8.7double −→ 8int . static_cast exprime ici une véritable conversion de valeur. 7.10.3 dynamic_cast : vérifier un type dans une hiérarchie polymorphe dynamic_cast intervient principalement dans les hiérarchies de classes polymorphes. Considérons : 1 2 Forme* forme = …; 3 4 Carre* carre = dynamic_cast(forme); Le pointeur forme possède statiquement le type Forme*, mais l’objet qu’il désigne peut réellement être un Carre, un Cercle ou un autre type dérivé. dynamic_cast permet ici de vérifier à l’exécution si l’objet est compatible avec le type demandé. 7.10.4 const_cast : modifier une qualification const_cast possède un rôle très différent. Il permet de modifier certaines qualifications d’un type, principalement const. Par exemple : 1 2 3 4 5 6 int a = 5; const int* p = &a; int* q = const_cast(p); L’objet a reste le même objet. Le transtypage modifie ici la qualification de l’accès utilisé pour l’atteindre. 7.10.4.1 reinterpret_cast : effectuer une conversion de bas niveau reinterpret_cast est destiné à certaines conversions de bas niveau. Par exemple : 1 2 std::uintptr_t adresse = reinterpret_cast(p); 23/08/2026
Accueil
37/42 2026 – Partie II C++ Conversions de types et transtypage nous permet, lorsque std::uintptr_t est disponible, de convertir une valeur de pointeur sous une forme entière capable de la représenter. Ce mécanisme ne doit pas être confondu avec une conversion numérique ordinaire. 7.10.4.2 Tableau comparatif Opérateur Rôle principal Exemple static_cast Conversion vérifiable statiquement Vérification de type dans une hiérarchie polymorphe Modification des qualifications const ou volatile Certaines conversions de bas niveau static_cast(n) dynamic_cast const_cast reinterpret_cast dynamic_cast(p) const_cast(p) reinterpret_cast(p) static_cast −→ convertir dynamic_cast −→ vérifier dynamiquement const_cast −→ modifier une qualification reinterpret_cast −→ conversion de bas niveau 7.10.5 Quatre opérateurs, quatre questions différentes Les quatre opérateurs ne constituent donc pas quatre manières concurrentes d’effectuer exactement la même opération. Ils répondent à des questions différentes. Avec static_cast, nous demandons principalement : « Cette valeur peut-elle être convertie selon les règles prévues pour static_cast ? » Avec dynamic_cast : « L’objet désigné possède-t-il réellement un type compatible avec celui que je demande ? » Avec const_cast : « Je souhaite modifier la qualification const ou volatile de cet accès. » Avec reinterpret_cast : « J’effectue volontairement cette conversion de bas niveau entre les représentations de types autorisées par cet opérateur. » 7.10.6 Et le transtypage de style C ? Le transtypage hérité du langage C possède une syntaxe beaucoup plus générale : 1 (type)expression Par exemple : 1 (int)x Cette syntaxe indique le type souhaité, mais elle ne distingue pas explicitement les différentes catégories de transtypages que le C++ moderne sépare au moyen de ses opérateurs spécialisés. C’est l’une des principales raisons pour lesquelles les opérateurs de transtypage C++ sont généralement préférés dans du code C++ moderne. 23/08/2026
Accueil
38/42 2026 – Partie II C++ 7.10.7 Conversions de types et transtypage Des responsabilités différentes Tous les transtypages exigent que le programmeur comprenne la conversion demandée, mais certains nécessitent une vigilance particulière. Une conversion numérique avec static_cast peut, par exemple, entraîner une perte d’information : 1 static_cast(8.7) dynamic_cast fournit au contraire un mécanisme permettant de vérifier dynamiquement certaines conversions dans une hiérarchie polymorphe. const_cast peut conduire à un comportement indéfini si l’on tente ensuite de modifier un objet réellement constant. Enfin, reinterpret_cast intervient dans des opérations de bas niveau pour lesquelles la validité de l’utilisation ultérieure du résultat demande une connaissance précise des types, des objets et de leur représentation. À retenir • Le C++ possède quatre opérateurs de transtypage spécialisés : static_cast, dynamic_cast, const_cast et reinterpret_cast. • Ces quatre opérateurs ne sont pas quatre syntaxes différentes pour effectuer la même opération. • static_cast est utilisé pour de nombreuses conversions dont la validité peut être déterminée à la compilation. • dynamic_cast permet notamment de vérifier à l’exécution certaines conversions dans une hiérarchie de classes polymorphes. • const_cast permet de modifier certaines qualifications, principalement const. • reinterpret_cast est destiné à certaines conversions de bas niveau et exige une connaissance précise de l’utilisation qui sera faite du résultat. • Le nom de chaque opérateur renseigne le lecteur sur la nature du transtypage demandé. • Le transtypage de style C reste valide en C++, mais il exprime moins précisément la catégorie de conversion demandée. • Dans du code C++ moderne, on privilégie généralement l’opérateur C++ correspondant exactement à l’intention du programmeur. 7.10.8 Conclusion Les types constituent l’un des mécanismes fondamentaux du C++. Ils permettent au compilateur de déterminer quelles opérations sont possibles, comment les valeurs doivent être interprétées et quelles incompatibilités doivent être signalées. Cependant, un programme réel doit fréquemment faire interagir des valeurs de types différents. Le C++ fournit pour cela deux grandes catégories de mécanismes : • les conversions implicites, appliquées automatiquement selon les règles du langage ; • les conversions explicites, ou transtypages, demandées directement par le programmeur. Les conversions implicites rendent possibles de nombreuses opérations naturelles : 23/08/2026
Accueil
39/42 2026 – Partie II C++ 1 2 Conversions de types et transtypage int n = 5; double x = n; mais elles peuvent également entraîner une perte d’information : 1 2 double x = 8.7; int n = x; Nous avons donc appris qu’une conversion autorisée n’est pas nécessairement une conversion exacte ni une conversion souhaitable. L’initialisation par accolades fournit par ailleurs une protection supplémentaire contre certaines conversions réductrices : 1 int n{8.7}; // erreur Le compilateur peut ainsi détecter certaines pertes d’information dès la compilation. Lorsqu’une conversion doit être explicitement demandée, le C++ moderne fournit quatre opérateurs spécialisés : static_cast −→ conversion statiquement vérifiable dynamic_cast −→ vérification dynamique dans une hiérarchie polymorphe const_cast −→ modification de qualifications reinterpret_cast −→ conversion de bas niveau Cette séparation permet d’exprimer beaucoup plus précisément l’intention du programmeur que l’ancienne syntaxe de transtypage héritée du C. Le système de types ne doit donc pas être considéré comme un obstacle que le transtypage permettrait de contourner. Au contraire, lorsqu’un cast devient nécessaire, son choix doit préciser la nature de l’opération que nous demandons au compilateur. Un transtypage bien choisi constitue ainsi une information supplémentaire dans le programme. Il indique non seulement vers quel type nous souhaitons aller, mais également, dans une large mesure, pourquoi et de quelle manière nous effectuons cette conversion. À retenir • Le C++ permet de convertir des valeurs entre différents types, mais toutes les conversions n’ont pas les mêmes conséquences. • Une conversion peut être implicite ou explicitement demandée par le programmeur. • Une conversion autorisée peut entraîner une perte d’information. • Les quatre opérateurs de transtypage du C++ correspondent à des intentions différentes. • Le choix d’un cast fait partie de la conception et de la lisibilité du programme. • Avant d’imposer une conversion, il faut toujours comprendre pourquoi elle est nécessaire.
Accueil
P2-Chap-07- Conversions de types et transtypage 23/08/2026
Accueil
40/42 2026 – Partie II C++ 23/08/2026 Conversions de types et transtypage
Accueil
41/42 Termes à ajouter au glossaire 42

Chapitre 8 - Les templates

Chapitre 8 – Les templates

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
8.1.1 La même opération avec un autre type . . . . . . . . . . . . . . . . . . . . . . 8.1.2 Une répétition inutile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.1.3 Séparer l’algorithme du type . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.2 Premier template de fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.2.4 Le paramètre de type T . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.2.6 Utilisation avec un autre type . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.3.1 Un paramètre représentant un type . . . . . . . . . . . . . . . . . . . . . . . . 8.3.2 T est utilisé comme un type . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.3.3 T peut représenter différents types . . . . . . . . . . . . . . . . . . . . . . . . 8.3.4 Le type doit permettre les opérations utilisées . . . . . . . . . . . . . . . . . . 8.3.5 Le nom du paramètre de type . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.4 Instanciation d’un template . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.4.1 Une première instanciation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.4.2 Plusieurs instanciations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.4.3 Une instanciation par type nécessaire . . . . . . . . . . . . . . . . . . . . . . . 8.4.4 Uniquement les versions nécessaires . . . . . . . . . . . . . . . . . . . . . . . . 8.4.5 Indiquer explicitement le type . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.5 Déduction automatique du type . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.5.1 Le type peut être déduit des arguments . . . . . . . . . . . . . . . . . . . . . 8.5.2 Un même template, plusieurs déductions . . . . . . . . . . . . . . . . . . . . . 8.5.3 Lorsque les types ne correspondent pas . . . . . . . . . . . . . . . . . . . . . . 8.5.4 Lever l’ambiguïté par une conversion explicite . . . . . . . . . . . . . . . . . . 8.5.5 Indiquer explicitement le type du template . . . . . . . . . . . . . . . . . . . . 8.5.6 Le type de retour ne participe pas à cette déduction . . . . . . . . . . . . . . 8.6 Plusieurs paramètres de type . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – Partie II C++ 8.6.1 Déclarer plusieurs paramètres de type . . . . . . . . . . . . . . . . . . . . . . . 8.6.2 Un nouveau problème : le type de retour . . . . . . . . . . . . . . . . . . . . . 8.6.3 Déduction automatique du type de retour . . . . . . . . . . . . . . . . . . . . 8.6.4 Plusieurs déductions dans une même instruction . . . . . . . . . . . . . . . . 8.6.5 Un seul type peut constituer une contrainte volontaire . . . . . . . . . . . . . 8.7 Les exigences imposées aux types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.7.1 Des exigences parfois implicites . . . . . . . . . . . . . . . . . . . . . . . . . . 8.7.2 Les types fondamentaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.7.3 Templates et surcharge des opérateurs . . . . . . . . . . . . . . . . . . . . . . 8.7.4 Le template ne connaît pas à l’avance les classes utilisées . . . . . . . . . . . 8.7.5 Des exigences exprimées par le code . . . . . . . . . . . . . . . . . . . . . . . 8.7.6 Éviter les copies inutiles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.7.7 Vers les concepts du C++ moderne . . . . . . . . . . . . . . . . . . . . . . . 8.8.1 Une classe liée à un type particulier . . . . . . . . . . . . . . . . . . . . . . . . 8.8.2 Transformer la classe en template . . . . . . . . . . . . . . . . . . . . . . . . . 8.8.3 Instanciation d’une classe template . . . . . . . . . . . . . . . . . . . . . . . . 8.8.5 Template et occupation mémoire . . . . . . . . . . . . . . . . . . . . . . . . . 8.8.7 Utiliser une classe comme argument de type . . . . . . . . . . . . . . . . . . . 8.8.8 Les exigences imposées au type restent présentes . . . . . . . . . . . . . . . . 8.8.9 Vers une classe Matrice . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.9 Plusieurs paramètres de type dans une classe . . . . . . . . . . . . . . . . . . . . . . . 8.9.1 Deux types indépendants . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.9.2 Chaque combinaison constitue un type distinct . . . . . . . . . . . . . . . . . 8.10 Séparer déclaration et définition d’une classe template . . . . . . . . . . . . . . . . . 8.10.1 Déclaration de la classe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.10.2 Définition des fonctions membres . . . . . . . . . . . . . . . . . . . . . . . . . 8.10.3 Pourquoi un fichier .cpp séparé pose-t-il un problème ? . . . . . . . . . . . . 8.10.4 Une première solution : tout placer dans le fichier d’en-tête . . . . . . . . . . 8.10.5 Séparer les définitions dans un fichier .tpp . . . . . . . . . . . . . . . . . . . 8.10.6 Pourquoi inclure le fichier .tpp à la fin ? . . . . . . . . . . . . . . . . . . . . . 8.10.7 Le fichier .tpp n’est pas un fichier .cpp . . . . . . . . . . . . . . . . . . . . . 8.10.8 Organisation du programme . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Accueil
Pourquoi les templates ? Supposons que nous voulions écrire une fonction retournant la plus grande de deux valeurs entières. Nous pouvons simplement écrire : int maximum(int a, int b) Nous pouvons alors l’utiliser de la manière habituelle : int resultat = maximum(5, 8); La fonction reçoit deux valeurs de type int et retourne également une valeur de type int. La même opération avec un autre type Nous souhaitons maintenant effectuer exactement la même opération avec deux valeurs de type double. La fonction précédente ne correspond plus exactement aux types que nous voulons manipuler. Nous pouvons naturellement profiter de la surcharge de fonctions et ajouter une seconde version : double maximum(double a, double b) 2026 – Partie II C++ Ces deux fonctions sont parfaitement valides. Mais observons-les attentivement. Le traitement effectué est exactement le même : • comparer les deux arguments avec l’opérateur > ; • retourner le premier si celui-ci est le plus grand ; • retourner le second dans le cas contraire. La seule différence entre les deux fonctions est le type utilisé. On pourrait imaginer avoir maximum(double, double) maximum(float, float) Une répétition inutile Nous pourrions continuer à surcharger la fonction maximum() pour tous les types dont nous avons besoin. Cette solution présente cependant un défaut évident : nous recopions plusieurs fois le même algorithme uniquement parce que le type des données change. Nous aimerions pouvoir exprimer quelque chose comme : « Voici l’algorithme de maximum(). Utilise-le avec le type dont j’aurai besoin. » Séparer l’algorithme du type Le C++ permet de définir un modèle de fonction dans lequel certains types ne sont pas encore fixés. Ce modèle porte le nom de template. Au lieu d’écrire séparément : int maximum(int a, int b); double maximum(double a, double b); float maximum(float a, float b); nous allons pouvoir écrire une seule définition capable de servir de modèle pour plusieurs types. Le type nécessaire sera déterminé lorsque le template sera utilisé. Il ne faut cependant pas imaginer qu’un template crée une fonction manipulant un mystérieux « type universel ». Le C++ reste fortement typé. Lorsqu’une version du template est nécessaire pour un type donné, le compilateur travaille avec ce type précis. Nous verrons que les templates constituent donc essentiellement un mécanisme permettant au compilateur de produire du code adapté aux types utilisés.
Accueil
2026 – Partie II C++ • La surcharge permet d’écrire plusieurs fonctions portant le même nom mais utilisant des • Lorsque seul le type change et que l’algorithme reste identique, cette solution peut conduire à recopier plusieurs fois le même code. • Un template permet de définir un modèle dans lequel certains types ne sont pas encore • Le type concret sera déterminé lors de l’utilisation du template. • Les templates ne suppriment pas le typage fort du C++ : le compilateur travaille finalement avec des types parfaitement déterminés. Premier template de fonction Nous avons vu que plusieurs fonctions peuvent posséder exactement le même algorithme tout en ne différant que par le type de leurs paramètres. int maximum(int a, int b) double maximum(double a, double b) réalisent exactement la même opération. Nous allons maintenant remplacer ces différentes fonctions par un seul modèle. Nous pouvons écrire : template <typename T>
Accueil
2026 – Partie II C++ Cette déclaration introduit notre premier template de fonction. Le mot-clé template template <typename T> indique que la définition qui suit est un template. Autrement dit, nous ne définissons pas encore une fonction utilisant un type précis comme int ou double. Nous définissons un modèle à partir duquel le compilateur pourra produire des fonctions utilisant contiennent les paramètres du template. Dans notre exemple : déclare un paramètre représentant un type. Le mot-clé typename indique que le paramètre qui suit représente un type. T représente donc un type qui sera déterminé lors de l’utilisation du template. Le paramètre de type T Le symbole T est simplement un nom choisi par le programmeur. Par convention, la lettre T est souvent utilisée pour représenter un type générique. Nous pourrions cependant écrire, par exemple : template <typename Type> Type maximum(Type a, Type b)
Accueil
2026 – Partie II C++ Le fonctionnement serait identique. La lettre T ne possède donc aucune signification particulière dans • la fonction reçoit deux paramètres du même type T ; • elle retourne également une valeur de type T. Le type T n’est pas encore fixé lors de l’écriture du template. Première utilisation Considérons maintenant : Les deux arguments transmis à maximum() sont de type int. Le compilateur peut donc déterminer que, pour cet appel, Le template peut alors être compris comme un modèle permettant au compilateur de produire une fonction équivalente à : int maximum(int a, int b) Cette fonction n’a pas été écrite directement par le programmeur. Elle est obtenue à partir du template pour le type nécessaire. Utilisation avec un autre type Nous pouvons ensuite écrire :
Accueil
2026 – Partie II C++ Cette fois, les deux arguments sont de type double. Le compilateur détermine donc : Il peut produire une version correspondant à : double maximum(double a, double b) Nous avons donc écrit une seule fois l’algorithme : template <typename T> mais ce modèle peut être utilisé avec plusieurs types. • Un template de fonction commence par une déclaration de la forme : template <typename T> • T représente un type qui n’est pas encore fixé lors de l’écriture du template. • Lors de l’utilisation de la fonction, le compilateur détermine le type nécessaire à partir • Pour un appel avec deux int, le compilateur utilise T = int. • Pour un appel avec deux double, il utilise T = double. • Le template constitue donc un modèle permettant de produire des fonctions adaptées aux types réellement utilisés. Le paramètre de type T Dans notre premier template, nous avons écrit : template <typename T>
Accueil
2026 – Partie II C++ La lettre T apparaît à plusieurs endroits dans cette définition. Il est important de comprendre précisément ce qu’elle représente. Un paramètre représentant un type Dans une fonction classique : n est un paramètre de la fonction. Sa valeur n’est pas connue lors de l’écriture de la fonction. Elle sera fournie lors de l’appel : template <typename T> T joue également le rôle d’un paramètre, mais il ne représente pas une valeur. Il représente un type. Fig. 8.1 – Représentation d’un type T et d’une variable n. T est utilisé comme un type Une fois déclaré par : template <typename T> T peut être utilisé dans la définition du template aux endroits où un type est attendu. le premier T indique le type de retour de la fonction. Les deux suivants indiquent les types des paramètres a et b. Fig. 8.2 – Schéma Le même paramètre de type est utilisé plusieurs fois dans la déclaration.
Accueil
2026 – Partie II C++ Lorsque le compilateur détermine la valeur de T, toutes les occurrences de T correspondent au même conduit à une fonction correspondant à : double maximum(double a, double b); T peut représenter différents types Le paramètre T n’est pas réservé aux types fondamentaux du C++. Il peut par exemple représenter : mais il peut également représenter un type défini par le programmeur, comme une classe. Un template peut donc être utilisé aussi bien avec des types fournis par le langage qu’avec nos propres Le type doit permettre les opérations utilisées Un template ne peut pas effectuer magiquement une opération qui n’existe pas pour le type utilisé. Notre fonction contient : Le type représenté par T doit donc permettre l’utilisation de l’opérateur > entre les deux objets. Cette condition est naturellement satisfaite pour : car l’opérateur > est défini pour ces types. Si nous voulions utiliser le même template avec une classe créée par le programmeur, l’opération utilisée par le template devrait également être définie pour cette classe. Nous retrouvons ici directement la notion de surcharge d’opérateur étudiée précédemment. Le nom du paramètre de type La lettre T est une convention très répandue, mais elle n’est pas imposée par le C++. Nous pourrions écrire : template <typename Type> Type maximum(Type a, Type b)
Accueil
2026 – Partie II C++ template <typename Nombre> Nombre maximum(Nombre a, Nombre b) Pour le compilateur, le principe est exactement le même. Pour déclarer un paramètre de type, le C++ permet également l’écriture : Dans ce contexte, elle est équivalente à : template <typename T> Le mot class ne signifie pas ici que T doit obligatoirement représenter une classe. peut parfaitement être utilisé avec T = int. Dans ce cours, nous utiliserons principalement typename, qui exprime directement que le paramètre représente un type.
Accueil
2026 – Partie II C++ template <typename T> T est un paramètre représentant un type. • Contrairement au paramètre d’une fonction classique, il ne représente pas une valeur mais un type utilisé lors de la compilation. • Toutes les occurrences de T correspondent au même type pour une instanciation donnée • T peut représenter un type fondamental ou un type défini par le programmeur. • Le type utilisé doit permettre les opérations effectuées dans le template. • Le nom T est une convention et non un mot réservé du C++. • Dans une déclaration de paramètre de type, typename T et class T sont équivalents. Instanciation d’un template Lorsque nous écrivons : template <typename T> nous définissons un modèle de fonction. À ce stade, aucune valeur particulière n’est associée au paramètre de type T. C’est l’utilisation du template avec un type concret qui va conduire le compilateur à produire la fonction correspondante. Ce mécanisme porte le nom d’instanciation du template. Une première instanciation les deux arguments sont de type int. Le compilateur détermine donc que le paramètre de type T doit correspondre à int. Il instancie alors une version du template pour ce type. Nous pouvons nous représenter cette version comme étant équivalente à : int maximum(int a, int b)
Accueil
2026 – Partie II C++ Plusieurs instanciations Si le même programme contient : avec a et b de type int, puis x et y de type double, le compilateur a besoin de deux versions différentes du template. Nous pouvons les représenter schématiquement par : int maximum(int a, int b); double maximum(double a, double b); Une même définition de template conduit donc ici à deux instanciations. Une instanciation par type nécessaire Plusieurs appels utilisant le même type ne nécessitent pas une nouvelle version du template pour utilisent tous le template avec le type int. Il suffit donc d’une version correspondant à ce type. Il faut distinguer : l’appel d’une fonction l’instanciation d’un template pour un type donné. Uniquement les versions nécessaires La définition d’un template ne provoque pas la création de versions pour tous les types imaginables. Le compilateur instancie les versions nécessaires en fonction de l’utilisation qui est faite du template. Si notre programme utilise uniquement : il n’a aucune raison d’instancier une version pour double, float ou une classe créée par le programmeur. Les versions nécessaires apparaissent donc en fonction des types avec lesquels le template est effectivement utilisé.
Accueil
2026 – Partie II C++ Indiquer explicitement le type Il est également possible d’indiquer explicitement le type avec lequel nous voulons utiliser le template : Dans cette écriture : indique que le paramètre de type T doit correspondre à int. maximum<double>(5.2, 8.7); Il existe ici une analogie avec les paramètres et les arguments d’une fonction ordinaire. template <typename T> T est un paramètre de template. int est un argument de template. • La définition d’un template constitue un modèle à partir duquel le compilateur peut produire des fonctions concrètes. • La production d’une version du template pour un type donné porte le nom • Un template peut être instancié pour plusieurs types différents dans un même programme. • Plusieurs appels utilisant le même type peuvent utiliser la même instanciation du template. • Le compilateur ne produit pas toutes les versions imaginables du template, mais les versions nécessaires à son utilisation. • Le type peut être indiqué explicitement : • Dans template <typename T>, T est un paramètre de template, tandis que dans maximum<int>(), int est un argument de template.
Accueil
2026 – Partie II C++ Déduction automatique du type Nous avons vu qu’un template de fonction permet de définir un modèle générique : template <typename T> return (a > b) ? a : b; Nous pouvons appeler explicitement cette fonction en indiquant le type souhaité : int resultat = maximum<int>(5, 8); Le compilateur utilise alors int à la place du paramètre de type T. Le type peut être déduit des arguments Dans de nombreux appels de fonctions templates, il n’est pas nécessaire d’indiquer explicitement le type utilisé pour remplacer le paramètre T. int resultat = maximum(5, 8); Les deux arguments : Le compilateur compare alors les types des arguments avec les paramètres du template : Il peut ensuite instancier la fonction correspondante. Un même template, plusieurs déductions Le même template peut naturellement conduire à différentes instanciations : int a = maximum(5, 8); double b = maximum(5.2, 8.7); char c = maximum(’a’, ’z’); Le compilateur peut successivement déduire :
Accueil
2026 – Partie II C++ À partir d’un seul modèle de fonction, plusieurs fonctions concrètes peuvent donc être instanciées. Lorsque les types ne correspondent pas Considérons maintenant : Le premier argument est de type int : Le second est de type double : Or notre template utilise un seul paramètre de type T pour les deux arguments : Le compilateur devrait donc déduire simultanément deux types différents pour le même paramètre T. La déduction échoue. Lever l’ambiguïté par une conversion explicite Nous pouvons rendre les types des deux arguments compatibles avant que la déduction du template double resultat = maximum( static_cast<double>(5), Le compilateur considère maintenant deux arguments de type double. Il peut donc déduire sans ambiguïté : Le transtypage intervient ici avant la déduction du paramètre du template. Indiquer explicitement le type du template Nous pouvons également écrire : double resultat = maximum<double>(5, 8.7); Cette fois, nous indiquons explicitement : Le compilateur n’a donc plus à déduire T à partir des deux arguments. La fonction instanciée possède des paramètres de type double : double maximum(double a, double b); La valeur entière 5 peut alors être convertie en double pour être transmise au premier paramètre.
Accueil
2026 – Partie II C++ Le type de retour ne participe pas à cette déduction double resultat = maximum(5, 8); Le type double de resultat ne conduit pas ici à déduire : La déduction est effectuée à partir des arguments transmis aux paramètres du template. Comme 5 et 8 sont de type int, nous obtenons : La fonction instanciée retourne donc un int. Cette valeur est ensuite convertie en double pour initialiser resultat. template <typename T> return (a > b) ? a : b; << maximum(5.2, 8.7) static_cast<double>(5), << maximum<double>(5, 8.7)
Accueil
2026 – Partie II C++ • Lors de l’appel d’une fonction template, le compilateur peut souvent déduire automatiquement le paramètre de type à partir des arguments. les deux arguments sont de type int. Le compilateur peut donc déduire : • Il n’est alors pas nécessaire d’écrire explicitement : • Si plusieurs paramètres dépendent du même paramètre de type T, les déductions obtenues doivent être compatibles. échoue, car les arguments conduisent ici à déduire à la fois int et double pour le même • Une conversion explicite peut être effectuée avant l’appel afin de rendre les types compatibles : maximum(static_cast<double>(5), 8.7) • Il est également possible d’imposer explicitement le paramètre du template : maximum<double>(5, 8.7) • Le type de la variable recevant le résultat ne détermine pas, dans notre exemple, le type T déduit à partir des arguments. Plusieurs paramètres de type Notre première fonction template utilise un seul paramètre de type : template <typename T> return (a > b) ? a : b; Cette conception impose que les deux paramètres a et b permettent de déduire un même type T. fonctionne puisque les deux arguments sont de type int.
Accueil
2026 – Partie II C++ ne permet pas de déduire un type unique pour T. Déclarer plusieurs paramètres de type Un template peut posséder plusieurs paramètres de type. template <typename T, typename U> déclare deux paramètres de type indépendants : Ils peuvent représenter le même type ou des types différents selon l’utilisation du template. Un nouveau problème : le type de retour Considérons une première généralisation : template <typename T, typename U> return (a > b) ? a : b; permet maintenant de déduire : La comparaison entre les deux valeurs est possible. Cependant, le type de retour de la fonction a été La fonction doit donc retourner un int. Choisir systématiquement le type du premier ou du second argument ne constitue donc pas une solution générale. Le type du résultat devrait dépendre de l’expression réellement retournée, et non de la position arbitraire de l’un des arguments. Déduction automatique du type de retour En C++ moderne, nous pouvons également demander au compilateur de déduire le type de retour template <typename T, typename U> auto maximum(T a, U b) return (a > b) ? a : b; Le mot-clé auto indique ici que le type de retour sera déduit à partir de l’expression retournée.
Accueil
2026 – Partie II C++ Plusieurs déductions dans une même instruction auto resultat = maximum(5, 8.7); Plusieurs mécanismes de déduction interviennent successivement. Pour l’appel du template, le compilateur déduit : à partir de 5, et : Il détermine ensuite le type de l’expression retournée par la fonction et peut en déduire le type de retour indiqué par auto. Enfin, le auto utilisé pour déclarer resultat est lui-même déduit à partir de la valeur retournée par la fonction. Fig. 8.3 – Diagramme template <typename T, typename U> auto maximum(T a, U b) return (a > b) ? a : b; auto a = maximum(5, 8);
Accueil
2026 – Partie II C++ auto b = maximum(5.2, 8.7); auto c = maximum(5, 8.7); auto d = maximum(8.7, 5); std::cout << a << std::endl; std::cout << b << std::endl; std::cout << c << std::endl; std::cout << d << std::endl; La présence de deux paramètres de type indépendants n’impose pas que les types correspondants le compilateur peut déduire : Les paramètres de type sont indépendants, mais ils peuvent naturellement recevoir le même type. Un seul type peut constituer une contrainte volontaire Utiliser plusieurs paramètres de type rend un template plus général, mais cela ne signifie pas que cette solution soit toujours préférable. template <typename T> exprime une contrainte importante : les deux paramètres doivent correspondre au même type T lors de la déduction. Cette contrainte peut être parfaitement volontaire. template <typename T, typename U> auto maximum(T a, U b) autorise deux types indépendants. Le choix dépend donc du contrat que nous souhaitons donner à
Accueil
2026 – Partie II C++ • Un template peut posséder plusieurs paramètres de type : template <typename T, typename U> • Les paramètres de type sont indépendants et peuvent représenter des types différents template <typename T, typename U> auto maximum(T a, U b) permet de déduire : • Choisir arbitrairement T ou U comme type de retour peut entraîner une conversion indésirable du résultat. • Le mot-clé auto peut permettre au compilateur de déduire le type de retour d’une fonction à partir de l’expression retournée. • La déduction des paramètres du template et la déduction du type de retour sont des mécanismes distincts. • Plusieurs paramètres de type offrent davantage de généralité, mais un template ne doit accepter que les combinaisons de types qui ont un sens pour l’opération considérée. Les exigences imposées aux types Un template permet d’écrire un algorithme sans fixer à l’avance tous les types avec lesquels il sera Cela ne signifie cependant pas que n’importe quel type pourra être substitué à un paramètre de type. Considérons notre fonction : template <typename T, typename U> auto maximum(T a, U b) return (a > b) ? a : b; Pour que cette fonction puisse être instanciée avec deux types donnés, toutes les opérations effectuées dans son corps doivent avoir un sens pour ces types. Des exigences parfois implicites Lors de l’écriture d’un template, les opérations présentes dans son corps imposent implicitement certaines exigences aux types utilisés.
Accueil
2026 – Partie II C++ return (a > b) ? a : b; doit tout d’abord être valide. Les types représentés par T et U doivent donc permettre cette comparaison. L’expression conditionnelle : doit également pouvoir produire un résultat dont le type peut être déterminé. Fig. 8.4 – Schéma. Les types fondamentaux Avec les types numériques fondamentaux, de nombreuses opérations sont déjà définies par le langage. conduit à utiliser les types : L’opérateur > peut être utilisé entre ces deux valeurs selon les règles de conversion du langage. Notre template peut donc être instancié. Templates et surcharge des opérateurs La situation devient particulièrement intéressante lorsque le paramètre d’un template représente une classe définie par le programmeur. Considérons deux objets de notre classe Complexe : Complexe z1(3.0, 4.0); Complexe z2(2.0, 1.0); Nous pourrions être tentés d’écrire :
Accueil
2026 – Partie II C++ auto z = maximum(z1, z2); Le compilateur peut parfaitement déduire : Mais cette déduction ne suffit pas. Le compilateur doit maintenant vérifier si l’expression : est valide pour deux objets de type Complexe. Contrairement aux nombres réels, les nombres complexes ne possèdent pas d’ordre naturel permettant d’interpréter directement la relation « supérieur à ». Si nous souhaitons néanmoins définir une comparaison pour les besoins de notre programme, nous devons choisir explicitement un critère. Nous pouvons, par exemple, décider de comparer les complexes selon leur module. Comme la fonction racine carrée est croissante pour les valeurs positives, il suffit de comparer les carrés des modules : |z|2 = (Re z)2 + (Im z)2 . Nous pouvons alors surcharger l’opérateur > : bool operator>(const Complexe& autre) const double moduleCarre1 = + imaginaire * imaginaire; double moduleCarre2 = autre.reel * autre.reel + autre.imaginaire * autre.imaginaire; return moduleCarre1 > moduleCarre2; Le template ne connaît pas à l’avance les classes utilisées Notre fonction maximum n’a pas été spécialement conçue pour la classe Complexe. Elle ne contient aucune référence à cette classe. Elle utilise simplement une opération : Lors de l’instanciation, le compilateur vérifie si cette expression est valide avec les types effectivement La généricité du template repose donc moins sur l’identité précise des types que sur les opérations qu’ils permettent. Considérons maintenant :
Accueil
2026 – Partie II C++ template <typename T> T additionner(T a, T b) Cette fonction n’exige plus l’existence de l’opérateur >. Elle impose en revanche que l’expression : soit valide pour le type utilisé. Une classe fournissant une surcharge appropriée de operator+ peut donc être utilisée avec ce template. Des exigences exprimées par le code Dans les templates étudiés jusqu’ici, nous n’avons pas écrit explicitement une liste des opérations que doit fournir le type utilisé. Ces exigences apparaissent directement dans le corps du template. Si le template contient : cette expression doit être valide. cette expression doit également être valide. Si une opération nécessaire n’existe pas pour le type utilisé, l’instanciation correspondante ne peut pas être réalisée correctement et la compilation échoue. Complexe(double r, double i) : reel(r), imaginaire(i) bool operator>(const Complexe& autre) const
Accueil
2026 – Partie II C++ double moduleCarre1 = + imaginaire * imaginaire; double moduleCarre2 = autre.reel * autre.reel + autre.imaginaire * autre.imaginaire; return moduleCarre1 > moduleCarre2; friend std::ostream& operator<<( template <typename T> return (a > b) ? a : b; Complexe z1(3.0, 4.0); Complexe z2(2.0, 1.0); Éviter les copies inutiles Lorsque les objets manipulés peuvent être volumineux, il peut être préférable de recevoir les arguments par référence constante : template <typename T> T maximum(const T& a, const T& b) return (a > b) ? a : b;
Accueil
2026 – Partie II C++ La fonction peut ainsi examiner les objets sans les copier et sans les modifier. Le résultat est encore retourné par valeur dans cette version. Vers les concepts du C++ moderne Les exigences imposées par un template classique sont souvent exprimées implicitement par les opérations présentes dans son corps. Le C++ moderne fournit un mécanisme appelé concept permettant d’exprimer plus explicitement certaines contraintes imposées aux paramètres d’un template. Nous étudierons les concepts ultérieurement. Pour l’instant, nous retiendrons que la validité d’une instanciation dépend non seulement du type déduit, mais également des opérations que ce type permet d’effectuer. • Un template ne fonctionne pas nécessairement avec n’importe quel type. • Les opérations utilisées dans le template imposent des exigences aux types qui lui sont • Si un template contient : les types concernés doivent permettre cette comparaison. • Une classe définie par le programmeur peut satisfaire cette exigence en fournissant, lorsque cela a un sens, une surcharge appropriée de l’opérateur. • La déduction correcte d’un paramètre de type ne garantit pas que l’instanciation correspondante soit utilisable. • Il faut distinguer : déduction du type ̸= validité des opérations effectuées avec ce type • Les templates permettent ainsi d’écrire des algorithmes génériques capables de fonctionner aussi bien avec des types fondamentaux qu’avec des classes définies par le programmeur. • Le C++ moderne permet d’exprimer plus explicitement certaines exigences imposées aux types au moyen des concepts. Templates de classes Jusqu’à présent, nous avons utilisé les templates pour créer des modèles de fonctions. Le même principe peut être appliqué à une classe entière. Une classe template permet de définir une famille de classes dont certains types ne sont pas fixés lors de l’écriture de la classe. Ils seront précisés lors de son utilisation. Une classe liée à un type particulier Considérons la classe suivante :
Accueil
2026 – Partie II C++ int getValeur() const Cette classe permet de stocker une valeur de type int. Le type int intervient ici à plusieurs endroits : • dans le type de l’attribut valeur ; • dans le paramètre du constructeur ; • dans le type de retour de getValeur(). Si nous souhaitions construire une classe identique pour des double, nous pourrions naturellement recopier la classe et remplacer chaque int par double. Cette duplication est précisément ce que les templates permettent d’éviter. Transformer la classe en template Nous pouvons remplacer le type particulier int par un paramètre de type : template <typename T> Comme pour une fonction template, la déclaration : template <typename T>
Accueil
2026 – Partie II C++ introduit un paramètre de type appelé T. Le paramètre T peut être utilisé dans les différents éléments de la classe exactement comme le serait Il peut notamment déterminer : • le type d’un attribut ; • le type d’un paramètre de fonction membre ; • le type de retour d’une fonction membre. Instanciation d’une classe template Pour créer un objet à partir d’une classe template, nous pouvons préciser le type utilisé comme argument du template : désigne une spécialisation de la classe template Boite pour laquelle : Le compilateur dispose ainsi de toutes les informations nécessaires pour former le type correspondant. Le même template peut être utilisé avec un autre type : Boite<double> b2(3.14); Le membre valeur, le paramètre du constructeur et le type de retour de getValeur() utilisent donc tous le type double. Boite<double> b2(3.14); Les objets b1 et b2 ne sont pas simplement deux objets de même type contenant des données différentes. Leurs types sont respectivement : Ces deux types sont distincts.
Accueil
2026 – Partie II C++ Template et occupation mémoire Le paramètre de type d’un template est utilisé par le compilateur pour construire les types nécessaires au programme. Il n’est pas nécessaire de stocker dans chaque objet une information supplémentaire indiquant le type utilisé pour remplacer T. template <typename T> contient simplement son membre de type int, sous réserve des règles habituelles d’alignement et de représentation des objets. L’utilisation de plusieurs instanciations d’un template peut en revanche augmenter la taille du code compilé, puisque différentes versions de certaines fonctions peuvent être générées pour différents Il faut donc distinguer : • la mémoire occupée par les objets pendant l’exécution ; • la taille du code correspondant aux différentes instanciations du template. Cette distinction prendra une importance particulière dans les systèmes embarqués, où la mémoire contenant le programme est limitée. Une famille de classes Un seul template peut permettre au compilateur de former plusieurs types : Boite<double> b2(3.14); Boite<char> b3(’A’); Nous obtenons ainsi trois types distincts : Une classe template peut donc être considérée comme un modèle permettant de définir une famille Utiliser une classe comme argument de type Le paramètre d’un template n’est pas limité aux types fondamentaux. Une classe définie par le programmeur peut également être utilisée : Complexe z(3.0, 4.0); Boite<Complexe> b(z);
Accueil
2026 – Partie II C++ Dans cette instanciation : Le membre valeur est donc lui-même un objet de type Complexe. Les exigences imposées au type restent présentes Comme pour les fonctions templates, les opérations utilisées dans une classe template imposent des exigences au type choisi. void afficher() const std::cout << valeur << std::endl; le type T doit permettre l’utilisation de l’expression : Pour une classe définie par le programmeur, cela peut notamment nécessiter une surcharge appropriée de l’opérateur <<. Vers une classe Matrice Le même mécanisme pourra être utilisé pour construire une classe Matrice indépendante du type particulier de ses coefficients. Nous pourrons ainsi écrire : template <typename T> std::size_t colonnes; pourra alors devenir, selon l’instanciation : Nous pourrons ainsi créer, à partir d’un même modèle de classe :
Accueil
2026 – Partie II C++ Nous construirons cette classe dans un chapitre ultérieur. Code complet de la classe Boite : template <typename T> Boite<double> b2(3.14); Boite<char> b3(’A’);
Accueil
2026 – Partie II C++ • Un template peut être appliqué à une classe entière. • Une classe template est introduite par une déclaration telle que : template <typename T> • Le paramètre de type T peut être utilisé pour définir les attributs, les paramètres et les types de retour des fonctions membres. • Une instanciation telle que : utilise int comme argument du paramètre de type T. • Deux instanciations telles que : constituent deux types distincts. • Une classe template peut donc être considérée comme un modèle permettant de construire une famille de types. • Le paramètre de type n’est pas stocké comme une information supplémentaire dans chaque objet. Il est exploité lors de la compilation. • Plusieurs instanciations peuvent en revanche augmenter la taille du code compilé. • Un paramètre de type peut représenter aussi bien un type fondamental qu’une classe définie par le programmeur. • Comme pour les fonctions templates, les opérations utilisées dans la classe imposent des exigences aux types avec lesquels elle est instanciée. Plusieurs paramètres de type dans une classe Une classe template peut utiliser plusieurs paramètres de type. Nous avons jusqu’ici écrit : template <typename T> mais rien n’impose de se limiter à un seul paramètre. Nous pouvons par exemple écrire : template <typename T, typename U> Les paramètres de type T et U sont alors indépendants.
Accueil
2026 – Partie II C++ Deux types indépendants template <typename T, typename U> T getPremier() const Le membre premier possède le type représenté par T. Le membre second possède le type représenté par U. Les paramètres de type étant indépendants, rien n’impose qu’ils représentent des types différents. Paire<double, double> p(2.5, 7.8); est parfaitement valide. Chaque combinaison constitue un type distinct Les instanciations : constituent des types différents. Le compilateur forme donc autant de types concrets que nécessaire à partir du même modèle de
Accueil
2026 – Partie II C++ Fig. 8.5 – Schéma. Les paramètres de type peuvent naturellement représenter des classes définies par le programmeur. Paire<Complexe, int> p(z, 3); est possible si les opérations utilisées par la classe Paire sont compatibles avec le type Complexe. • Une classe template peut posséder plusieurs paramètres de type. • On peut écrire : template <typename T, typename U> pour déclarer deux paramètres de type indépendants. • Les paramètres peuvent représenter des types différents ou identiques.

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Paire
Classe template Paire
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Boite
Classe template Boite
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Chapitre 9 - Classe MatriceT

Chapitre 9 – Classe MatriceT

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
9.1 Pourquoi rendre Matrice générique ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.1.1 Le type des coefficients devient un paramètre . . . . . . . . . . . . . . . . . . 9.1.2 Faire évoluer progressivement la classe . . . . . . . . . . . . . . . . . . . . . . 9.2 Transformer la classe Matrice . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.2.1 Quels éléments doivent changer ? . . . . . . . . . . . . . . . . . . . . . . . . . 9.2.2 Les fonctions qui manipulent un coefficient . . . . . . . . . . . . . . . . . . . 9.2.3 L’allocation dynamique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3 Les définitions dans le fichier Matrice.tpp . . . . . . . . . . . . . . . . . . . . . . . . 9.3.2 Le constructeur de copie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3.3 L’opérateur d’affectation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.4 Les opérations sur les coefficients . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.4.2 Modifier un coefficient . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.4.4 Transposer une matrice . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.4.5 Une généralisation presque mécanique . . . . . . . . . . . . . . . . . . . . . . 9.5 La multiplication et le type T . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.5.1 Une hypothèse cachée dans l’algorithme . . . . . . . . . . . . . . . . . . . . . 9.5.3 L’initialisation par défaut . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.5.4 La multiplication générique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.5.5 Un template impose des exigences au type utilisé . . . . . . . . . . . . . . . . 9.6 Chaque instanciation constitue un type différent . . . . . . . . . . . . . . . . . . . . . 9.6.1 Deux classes concrètes différentes . . . . . . . . . . . . . . . . . . . . . . . . . 9.6.2 Une conséquence importante . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.7 Hériter d’une classe template . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.7.1 Une classe template dérivée d’une classe template . . . . . . . . . . . . . . . 9.7.2 Appeler le constructeur de la classe de base . . . . . . . . . . . . . . . . . . . 9.7.3 Accéder aux membres hérités . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.7.6 Templates et héritage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.8.1 D’une classe à une famille de classes . . . . . . . . . . . . . . . . . . . . . . . 9.8.2 Le code générique n’est pas nécessairement universel . . . . . . . . . . . . . . 9.8.3 Templates et programmation orientée objet . . . . . . . . . . . . . . . . . . . 9.8.4 Une synthèse de la programmation orientée objet . . . . . . . . . . . . . . . . 2026 – Partie II C++
Accueil
La classe Matrice nous a déjà accompagnés au cours de notre étude de la programmation orientée objet. Elle nous a notamment permis de mettre en pratique la construction et la destruction des objets, l’allocation dynamique, la copie des objets et la surcharge des opérateurs. Nous disposons maintenant d’un nouvel outil : les templates. Nous allons reprendre notre classe Matrice afin de la rendre indépendante du type particulier utilisé pour représenter ses coefficients. Ce chapitre constituera ainsi une synthèse de nombreuses notions étudiées dans cette partie. Pourquoi rendre Matrice générique ? Dans notre première version de la classe Matrice, les coefficients étaient représentés par des valeurs Nous avions donc notamment un attribut de la forme : double* coefficients; Ce choix signifie que toutes les matrices construites à partir de cette classe possèdent nécessairement des coefficients de type double. Nous pouvons cependant souhaiter manipuler différentes catégories de matrices. • une matrice d’entiers ; • une matrice de nombres réels représentés par des float ; • une matrice de nombres réels représentés par des double. Une première solution consisterait à écrire plusieurs classes presque identiques, chacune adaptée à un type particulier. Cette solution entraînerait une duplication importante du code. Le type des coefficients devient un paramètre Plutôt que de fixer le type des coefficients lors de l’écriture de la classe, nous pouvons en faire un paramètre de type : template <typename T> Le paramètre T représentera désormais le type des coefficients de la matrice. 2026 – Partie II C++ Faire évoluer progressivement la classe Transformer Matrice en classe template ne consiste cependant pas simplement à remplacer une occurrence de double par T. Le type des coefficients intervient dans plusieurs éléments de la classe : attributs, paramètres, valeurs retournées, allocation dynamique et opérateurs. Nous allons donc reprendre progressivement notre classe existante et examiner chaque élément afin de déterminer s’il dépend ou non du paramètre de type T. exercice : Classe Matrice pour des nb complexes Transformer la classe Matrice Nous allons maintenant transformer progressivement notre classe Matrice en une classe template. La première modification consiste à faire précéder sa déclaration par : template <typename T> La classe devient donc : template <typename T> À ce stade, T représente le type des coefficients qui sera choisi lors de l’instanciation de la classe. Quels éléments doivent changer ? Tous les éléments de la classe ne dépendent pas du type des coefficients. Par exemple, le nombre de lignes et le nombre de colonnes restent des entiers : Il n’y aurait évidemment aucun sens à les remplacer par des objets de type T. En revanche, notre ancien attribut : double* coefficients; dépend directement du type choisi pour représenter les coefficients. Le pointeur coefficients ne pointe donc plus nécessairement vers un tableau de double. Son type dépend maintenant de l’instanciation de la classe : Matrice<int> A(3, 3); Matrice<double> B(3, 3); Dans le premier cas, coefficients est un pointeur vers int. Dans le second, il s’agit d’un pointeur
Accueil
2026 – Partie II C++ Les fonctions qui manipulent un coefficient Les fonctions qui manipulent directement les coefficients doivent également être rendues indépendantes du type utilisé. Notre fonction permettant de lire un coefficient retournait auparavant un Elle retourne maintenant un objet de type T : De même, la fonction permettant de modifier un coefficient reçoit maintenant une valeur de type T : void setCoefficient( En revanche, les paramètres ligne et colonne restent des entiers : ils représentent des indices et non des coefficients. Le passage à une classe template ne consiste donc pas à remplacer systématiquement tous les types par T. Il faut déterminer quels éléments dépendent réellement du paramètre de type. L’allocation dynamique Le constructeur doit lui aussi tenir compte du type des coefficients. Dans notre ancienne classe, le tableau dynamique était créé par une instruction de la forme : new double[lignes * colonnes]{}; Le type double étant maintenant remplacé par le paramètre de type T, cette allocation devient : new T[lignes * colonnes]{}; Ainsi, une instanciation : Matrice<int> A(5, 3); entraînera une allocation d’un tableau de int, tandis que : Matrice<double> B(5, 3); entraînera une allocation d’un tableau de double. Le mécanisme d’allocation dynamique n’a pas changé. Seul le type des objets alloués est devenu Les définitions dans le fichier Matrice.tpp Dans une classe ordinaire, nous avons pris l’habitude de séparer la déclaration de la classe, placée dans un fichier d’en-tête .h, de la définition de ses méthodes, placée dans un fichier .cpp. Pour une classe template, la situation est différente. Lorsqu’il instancie une classe telle que :
Accueil
2026 – Partie II C++ Matrice<double> A(3, 3); le compilateur doit disposer non seulement de la déclaration du template, mais également de la définition des méthodes nécessaires à cette instanciation. Nous allons donc conserver la séparation entre les déclarations et les définitions, mais placer ces dernières dans un fichier : À la fin du fichier Matrice.h, nous écrivons alors : #include « Matrice.tpp » Le contenu du fichier Matrice.tpp devient ainsi visible chaque fois que le fichier Matrice.h est L’extension .tpp n’a aucune signification particulière pour le langage C++. Il s’agit d’une convention permettant de distinguer les définitions des méthodes d’une classe template des fichiers source .cpp ordinaires. Il serait également possible de placer directement toutes les définitions dans le fichier Dans notre ancienne classe, la définition du constructeur commençait par : Matrice::Matrice(int lignes, int colonnes) La classe étant maintenant un template, la définition doit préciser le paramètre de type utilisé : template <typename T> Matrice<T>::Matrice(int lignes, int colonnes) La première ligne rappelle que la définition qui suit appartient à un template utilisant le paramètre indique ensuite que la méthode appartient à l’instanciation Matrice<T>. Il faut donc bien distinguer : template <typename T> qui déclare le template de classe, de : Matrice<T>::Matrice(…) qui définit une méthode appartenant à une instanciation de ce template. Le constructeur peut alors être écrit :
Accueil
2026 – Partie II C++ template <typename T> Matrice<T>::Matrice(int lignes, int colonnes) : nombreLignes(lignes), nombreColonnes(colonnes), new T[lignes * colonnes]{} Lors de l’instanciation : Matrice<int> A(5, 3); le compilateur peut donc générer une version du constructeur dans laquelle T correspond à int. Matrice<double> B(5, 3); T correspond cette fois à double. Le constructeur de copie Le passage à une classe template ne modifie pas le principe de la copie profonde étudié précédemment. Chaque matrice doit toujours posséder sa propre zone de mémoire contenant ses coefficients. Le constructeur de copie devient : template <typename T> Matrice<T>::Matrice( const Matrice<T>& autre autre.nombreColonnes autre.getCoefficient(i, j) const Matrice<T>& autre indique que la matrice copiée possède le même type de coefficients que la matrice construite. Ainsi, le constructeur de copie d’une :
Accueil
2026 – Partie II C++ reçoit ici une autre Matrice<double>. • Rendre la classe générique ne change pas le principe de la copie profonde. • Une nouvelle zone mémoire est toujours allouée pour le nouvel objet, puis les coefficients sont copiés dans cette zone. L’opérateur d’affectation L’opérateur d’affectation doit subir la même adaptation. Son type de retour et son paramètre font maintenant référence à Matrice<T> : template <typename T> Matrice<T>& Matrice<T>::operator=( const Matrice<T>& autre delete[] coefficients; autre.nombreColonnes; autre.getCoefficient(i, j)
Accueil
2026 – Partie II C++ On retrouve exactement les étapes étudiées lors de la copie des objets : 1. vérifier qu’il ne s’agit pas d’une auto-affectation ; 2. libérer l’ancien tableau dynamique ; 3. recopier les dimensions ; 4. allouer un nouveau tableau ; 5. recopier les coefficients ; 6. retourner l’objet courant par référence. La seule modification véritablement liée au template concerne l’allocation : new T[nombreLignes * nombreColonnes] new double[nombreLignes * nombreColonnes] Le destructeur ne dépend pratiquement pas du type des coefficients. Sa définition doit cependant respecter la syntaxe d’une classe template : template <typename T> Matrice<T>::~Matrice() delete[] coefficients; L’instruction delete[] détruit les éléments du tableau puis libère la mémoire qui lui avait été attribuée. Le type réel des éléments dépend de T, mais le principe de gestion de la mémoire reste La gestion de la mémoire ne change pas. C’est le type des objets stockés qui devient générique. Les opérations sur les coefficients Une fois la gestion de la mémoire adaptée au paramètre de type T, nous pouvons examiner les méthodes qui manipulent directement les coefficients de la matrice. Pour certaines d’entre elles, la transformation est presque immédiate : il suffit de remplacer le type particulier utilisé précédemment par le paramètre de type T. Dans notre première version de la classe, la méthode getCoefficient() retournait une valeur de Le type des coefficients étant maintenant représenté par T, son type de retour doit lui aussi devenir template <typename T> T Matrice<T>::getCoefficient(
Accueil
2026 – Partie II C++ return coefficients[ ligne * nombreColonnes + colonne Ainsi, pour une matrice : Matrice<int> A(3, 3); la méthode retourne un int, tandis que pour : Matrice<double> B(3, 3); elle retourne un double. Le type retourné dépend donc directement de l’instanciation du template. Modifier un coefficient La même transformation s’applique à la méthode setCoefficient(). La valeur à enregistrer dans la matrice doit être du type correspondant aux coefficients : template <typename T> void Matrice<T>::setCoefficient( ligne * nombreColonnes + colonne Les paramètres ligne et colonne restent naturellement de type int, puisqu’ils représentent des En revanche, le paramètre valeur dépend du type des coefficients et doit donc être de type T. Les indices décrivent la structure de la matrice : ils restent des entiers. La valeur décrit son contenu : son type devient T. Afficher une matrice La méthode permettant d’afficher une matrice nécessite très peu de modifications : template <typename T> void Matrice<T>::afficherMatrice() const for (int i = 0; i < nombreLignes; ++i) for (int j = 0; j < nombreColonnes; ++j) << getCoefficient(i, j)
Accueil
2026 – Partie II C++ std::cout << std::endl; Le code de la méthode ne dépend explicitement d’aucun type particulier. La méthode getCoefficient() retourne automatiquement une valeur du type correspondant à l’instanciation de la matrice. Il existe cependant une condition implicite : l’expression std::cout << getCoefficient(i, j); doit être valide pour le type T utilisé. Cette condition est naturellement satisfaite pour les types usuels tels que int, float ou double. Transposer une matrice La transposition constitue un exemple particulièrement simple de généralisation. Cette opération ne modifie pas les coefficients et n’effectue aucune opération arithmétique sur eux. Elle échange simplement leurs positions. La méthode devient : template <typename T> Matrice<T> Matrice<T>::transposee() const Matrice<T> resultat( for (int i = 0; i < nombreLignes; ++i) for (int j = 0; j < nombreColonnes; ++j) resultat.setCoefficient( getCoefficient(i, j) indique que la matrice transposée possède le même type de coefficients que la matrice d’origine. Matrice<int> A(5, 3); auto B = A.transposee();
Accueil
2026 – Partie II C++ produit une matrice transposée de type Matrice<int> et de dimensions 3 × 5. De même, la variable locale : Matrice<T> resultat( est construite avec le même paramètre de type T. Une généralisation presque mécanique Les méthodes étudiées dans cette section ont pu être adaptées sans modifier leur algorithme. Le type double a été remplacé par T lorsque cela était nécessaire, mais les opérations réalisées sur la matrice sont restées identiques. Cette situation n’est cependant pas générale. Certaines méthodes effectuent elles-mêmes des calculs sur les coefficients. Elles peuvent alors contenir des hypothèses implicites sur leur type. La multiplication de deux matrices va nous fournir un premier exemple particulièrement intéressant La multiplication et le type T Les méthodes étudiées jusqu’à présent ont pu être rendues génériques sans modifier fondamentalement leur fonctionnement. La multiplication de deux matrices fait apparaître une difficulté supplémentaire. Dans notre ancienne classe, dont les coefficients étaient de type double, nous avions utilisé une variable permettant d’accumuler les différents produits : Cette écriture était parfaitement adaptée à une matrice dont les coefficients étaient nécessairement Ce n’est plus le cas avec Matrice<T>. Une hypothèse cachée dans l’algorithme Considérons deux matrices : Matrice<int> A(5, 3); Matrice<int> B(3, 5); Les coefficients des deux matrices sont des int. Le produit de ces deux matrices doit donc, dans notre implémentation, produire une nouvelle matrice dont les coefficients sont également des int. introduirait alors artificiellement un double dans un calcul effectué sur des coefficients de type int. Plus généralement, nous ne devons plus supposer que le type utilisé pour effectuer cette somme est double. Il doit dépendre du paramètre de type du template.
Accueil
2026 – Partie II C++ Une première transformation consiste donc à remplacer le type double par T : La variable somme possède ainsi le même type que les coefficients de la matrice. Il reste cependant à lui attribuer une valeur initiale. Pour calculer un coefficient du produit de deux matrices, cette variable doit initialement représenter L’initialisation par défaut Nous pourrions être tentés d’écrire : Cette écriture suppose cependant que le type T peut être initialisé à partir de la valeur entière 0. Une écriture plus générique consiste à utiliser l’initialisation par accolades : Pour les types numériques usuels, cette initialisation produit la valeur nulle correspondante. nous obtenons conceptuellement : Dans les deux cas, somme est initialisée à zéro. initialisation par défaut adaptée au type T La multiplication générique Nous pouvons maintenant adapter la méthode complète : template <typename T> Matrice<T> Matrice<T>::multiplier( const Matrice<T>& autre Matrice<T> resultat( autre.nombreColonnes for (int i = 0; i < nombreLignes; ++i)
Accueil
2026 – Partie II C++ j < autre.nombreColonnes; getCoefficient(i, k) autre.getCoefficient(k, j); resultat.setCoefficient( La structure de l’algorithme n’a pas changé. En revanche, les opérations effectuées sur les coefficients dépendent maintenant du type T. getCoefficient(i, k) * autre.getCoefficient(k, j) suppose que deux objets de type T peuvent être multipliés. suppose que le résultat obtenu peut être ajouté à un objet de type T. Un template impose des exigences au type utilisé template <typename T> ne signifie pas que n’importe quel type pourra nécessairement être utilisé avec toutes les méthodes Le code de chaque méthode effectue certaines opérations sur les objets de type T. Ces opérations doivent avoir un sens pour le type choisi lors de l’instanciation. Dans le cas de la multiplication de matrices, le type T doit notamment permettre les opérations nécessaires à l’initialisation, à la multiplication et à l’addition des coefficients.
Accueil
2026 – Partie II C++ Un template rend un type paramétrable, mais le code utilisé impose des opérations que ce type doit supporter. Par exemple, nous pouvons maintenant écrire : Matrice<int> A(5, 3); Matrice<int> B(3, 5); C.afficherMatrice(); auto C = A.multiplier(B); Le compilateur instancie alors la méthode multiplier() pour le type int. devient dans cette instanciation une variable de type int. Chaque instanciation constitue un type différent Lorsque nous écrivons : template <typename T> nous ne définissons pas une classe unique capable de changer de type pendant l’exécution du programme. Nous définissons un modèle à partir duquel le compilateur peut produire différentes classes Considérons par exemple : Matrice<int> A(3, 3); Matrice<double> B(3, 3); Le compilateur instancie le template avec deux paramètres de type différents. Nous obtenons donc deux types distincts : Dans le premier cas, le paramètre T correspond à int. Dans le second, il correspond à double. Matrice<int> ̸= Matrice<double> Deux classes concrètes différentes Pour une instanciation de type : les éléments dépendant de T sont adaptés au type int. Nous obtenons notamment conceptuellement :
Accueil
2026 – Partie II C++ et une méthode telle que : correspond alors à une méthode retournant un int. le même template conduit cette fois à un stockage de coefficients de type double et à des méthodes adaptées à ce type. Une conséquence importante Le fait que le langage C++ sache convertir une valeur de type int en une valeur de type double ne signifie pas que les classes obtenues à partir de ces deux types soient elles-mêmes interchangeables. utilise une conversion parfaitement définie entre deux types numériques. En revanche, les types : sont deux types de classes distincts. La possibilité de convertir les coefficients ne crée pas automatiquement une conversion entre les matrices qui les contiennent. Matrice<int> −→ Matrice<double> Il serait possible de concevoir notre classe de manière à autoriser certaines conversions entre différentes instanciations de Matrice. Cela nécessiterait cependant de définir explicitement le mécanisme permettant de réaliser cette conversion. Nous reviendrons sur ce type de situation dans les exercices. Hériter d’une classe template Notre classe MatriceCarree avait été construite comme une spécialisation de la classe Matrice. Une matrice carrée reste en effet une matrice, mais son nombre de lignes est nécessairement égal à son nombre de colonnes. Dans notre première implémentation, nous pouvions donc écrire : class MatriceCarree : public Matrice La classe de base Matrice étant maintenant devenue un template, cette déclaration doit être adaptée.
Accueil
2026 – Partie II C++ Une classe template dérivée d’une classe template Une matrice carrée doit elle aussi pouvoir contenir différents types de coefficients. Nous transformons donc MatriceCarree en classe template : template <typename T> class MatriceCarree : public Matrice<T> Le même paramètre de type T est transmis à la classe de base. Ainsi, une instanciation : MatriceCarree<int> A(3); MatriceCarree<double> B(3); MatriceCarree<double> −→ Matrice<double> Le paramètre T ne représente donc pas un nouveau type propre à la classe dérivée. Il est transmis à la classe de base afin que les deux classes manipulent le même type de coefficients. Appeler le constructeur de la classe de base Le constructeur d’une matrice carrée ne reçoit qu’une dimension, puisque son nombre de lignes et son nombre de colonnes sont identiques. Sa définition devient : template <typename T> MatriceCarree<T>::MatriceCarree(int ordre) : Matrice<T>(ordre, ordre) Comme précédemment, la ligne : template <typename T> indique que la définition appartient à un template. MatriceCarree<T>::MatriceCarree désigne le constructeur de la classe dérivée.
Accueil
2026 – Partie II C++ Matrice<T>(ordre, ordre) appelle le constructeur de la classe de base en lui transmettant deux dimensions identiques. Accéder aux membres hérités L’association entre templates et héritage fait apparaître une particularité du langage C++. Considérons la méthode permettant d’obtenir l’ordre d’une matrice carrée. Nous pourrions être tentés d’écrire : template <typename T> int MatriceCarree<T>::getOrdre() const return getNombreLignes(); La méthode getNombreLignes() est bien héritée de Matrice<T>. Cependant, Matrice<T> est une classe de base dont le type dépend du paramètre template T. Dans cette situation, lors de la première analyse du template, le compilateur ne recherche pas automatiquement certains noms dans une classe de base dépendante du paramètre de type. Nous pouvons lui indiquer explicitement que la fonction recherchée est un membre de l’objet courant La méthode devient alors : template <typename T> int MatriceCarree<T>::getOrdre() const return this->getNombreLignes(); this->getNombreLignes() L’écriture this-> indique explicitement au compilateur que getNombreLignes() doit être recherchée parmi les membres de l’objet courant, ce qui inclut les membres hérités de Matrice<T>. Cette syntaxe apparaît fréquemment lorsqu’une classe template dérive d’une classe dont le type dépend lui-même d’un paramètre template. Cette particularité n’est pas liée au fonctionnement mathématique de notre matrice. Elle provient des règles utilisées par le compilateur pour analyser les templates. Il n’est pas nécessaire d’en mémoriser immédiatement tous les détails. Il faut surtout retenir que l’héritage d’une classe template peut rendre nécessaire l’écriture this-> pour accéder à certains membres hérités.
Accueil
2026 – Partie II C++ Construire un mineur La méthode mineur() construit elle-même une nouvelle matrice carrée. Son type de retour doit donc tenir compte du paramètre de type : template <typename T> MatriceCarree<T> MatriceCarree<T>::mineur( int colonneSupprimee int ordre = this->getNombreLignes(); MatriceCarree<T> resultat(ordre – 1); int ligneMineur = 0; for (int i = 0; i < ordre; ++i) if (i == ligneSupprimee) int colonneMineur = 0; for (int j = 0; j < ordre; ++j) if (j == colonneSupprimee) resultat.setCoefficient( this->getCoefficient(i, j) La matrice obtenue doit conserver le type des coefficients de la matrice d’origine. Une MatriceCarree<int> produit donc ici un mineur de type MatriceCarree<int>, tandis qu’une MatriceCarree<double> produit un MatriceCarree<double>. À l’intérieur de ce contexte, le langage permet également dans certaines situations d’utiliser simplement le nom : pour désigner l’instanciation courante MatriceCarree<T>. L’écriture explicite :
Accueil
2026 – Partie II C++ sera néanmoins privilégiée ici afin de rendre le rôle du paramètre de type immédiatement visible. Notre méthode de calcul du déterminant peut continuer à utiliser les méthodes héritées de Matrice<T>. Sa définition commence par : template <typename T> double MatriceCarree<T>::determinant() const int ordre = getOrdre(); Nous avons choisi de conserver double comme type de retour du déterminant. Ce choix convient aux types numériques utilisés dans nos exemples et permet de conserver une Il ne s’agit cependant pas d’une obligation imposée par les templates. Une classe encore plus générique pourrait chercher à retourner une valeur dépendant elle aussi du type T. Une telle généralisation imposerait alors d’examiner plus précisément les opérations disponibles pour ce type. Exercice : adapter la classe afin d’étudier le cas d’une matrice dont les coefficients sont des nombres complexes. Examiner en particulier le type retourné par la méthode determinant(). Templates et héritage La transformation de MatriceCarree montre que les templates s’intègrent naturellement aux mécanismes de la programmation orientée objet. Une classe template peut dériver d’une autre classe template et lui transmettre son paramètre de template <typename T> class MatriceCarree : public Matrice<T> Les principes de l’héritage restent inchangés. Certaines particularités syntaxiques apparaissent cependant lorsque la classe de base dépend du paramètre template, notamment lors de l’accès aux Les templates ne remplacent pas l’héritage : ils permettent de le rendre lui aussi générique. La transformation de notre classe Matrice en Matrice<T> nous a permis d’appliquer les templates à une classe déjà relativement complète.
Accueil
2026 – Partie II C++ Contrairement aux premiers exemples de templates étudiés précédemment, nous ne sommes plus partis d’une fonction ou d’une petite classe créée spécialement pour découvrir le mécanisme. Nous avons repris une classe existante comportant déjà de nombreux éléments de la programmation orientée • des constructeurs et un destructeur ; • une allocation dynamique ; • un constructeur de copie ; • un opérateur d’affectation ; • des méthodes effectuant des opérations sur les données ; • une classe dérivée. Le passage aux templates n’a pas remis en cause ces mécanismes. Il a permis de remplacer un type fixé lors de l’écriture de la classe par un paramètre de type choisi lors de son utilisation. D’une classe à une famille de classes Notre ancienne classe imposait le type double à ses coefficients. template <typename T> nous définissons désormais un modèle permettant au compilateur de produire différentes classes Ces différentes instanciations utilisent le même algorithme général, mais manipulent des coefficients de types différents. Nous évitons ainsi d’écrire plusieurs classes dont le fonctionnement serait pratiquement identique. Le code générique n’est pas nécessairement universel Nous avons également constaté qu’un template ne rend pas automatiquement un programme compatible avec n’importe quel type. Une méthode telle que la multiplication utilise notamment des opérations de la forme : Le type utilisé pour instancier le template doit donc permettre ces opérations.
Accueil
2026 – Partie II C++ nous a également montré qu’une généralisation peut nécessiter de réexaminer certaines hypothèses qui étaient presque invisibles lorsque nous travaillions exclusivement avec des double. Écrire du code générique ne consiste pas seulement à remplacer un type par T. Il faut également examiner les opérations que le programme exige de ce type. Templates et programmation orientée objet La classe MatriceCarree<T> nous a finalement permis de vérifier que les templates s’intègrent aux mécanismes de l’héritage. Nous avons ainsi pu écrire : template <typename T> class MatriceCarree : public Matrice<T> Une classe template peut donc dériver d’une autre classe template tout en lui transmettant son Cette association entre templates et héritage introduit quelques particularités syntaxiques, comme l’utilisation possible de this-> pour accéder à certains membres d’une classe de base dépendant du paramètre template. Les principes fondamentaux de la programmation orientée objet restent cependant inchangés. Une synthèse de la programmation orientée objet Notre classe Matrice<T> constitue finalement une synthèse d’une grande partie des notions rencontrées dans cette partie. Elle associe notamment : • encapsulation des données ; • construction et destruction des objets ; • allocation dynamique ; • copie profonde ; • surcharge des opérateurs ; • et maintenant programmation générique à l’aide des templates. Nous sommes partis d’une classe capable de manipuler uniquement des matrices de double. Nous disposons maintenant d’un modèle de classe pouvant être adapté par le compilateur au type de coefficients demandé par le programme. Les templates constituent ainsi un outil puissant pour écrire du code réutilisable sans renoncer au typage strict du C++.
Accueil
2026 – Partie II C++ Jusqu’à présent, notre classe Matrice<T> gère elle-même la mémoire dynamique qu’elle utilise. Elle doit explicitement allouer cette mémoire : delete[] coefficients; Cette manière de procéder nous a permis de comprendre précisément les mécanismes mis en jeu : durée de vie des objets, allocation dynamique, destruction et copie profonde. Le C++ moderne propose cependant des outils permettant de conserver ce contrôle tout en confiant davantage de responsabilités aux objets eux-mêmes. Nous allons maintenant pouvoir étudier ces mécanismes et découvrir notamment le principe fondamental du RAII, ainsi que les outils modernes de gestion automatique des ressources. Nous avons appris à gérer explicitement les ressources. Nous pouvons maintenant apprendre à laisser les objets C++ les gérer pour nous.
Accueil
Accueil
Termes à ajouter au glossaire

Codes et programmes contenus dans le chapitre

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Matrice Template
Classe Matrice sous forme de template
.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
README.pdf
Explications sur l’utilisation des codes

Partie III : Gestion dynamique de la mémoire

Mécanismes fondamentaux de l’allocation dynamique – E/S – GnuPlot

Chapitre 1 - L'allocation dynamique de la mémoire

Chapitre 1 – L’allocation dynamique de la mémoire

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
1 L’allocation dynamique de mémoire 1.1 Toute variable occupe de la mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.1 Observons la disposition des variables en mémoire . . . . . . . . . . . . . . . . 1.1.2 Des variables de tailles différentes . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.3 L’ordre des types peut avoir une influence . . . . . . . . . . . . . . . . . . . . 1.1.4 Un pointeur est lui-même une variable . . . . . . . . . . . . . . . . . . . . . . 1.1.5 Quelle taille occupe un pointeur ? . . . . . . . . . . . . . . . . . . . . . . . . . 1.2 La durée de vie automatique et la pile . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3 Lorsque plusieurs appels existent simultanément . . . . . . . . . . . . . . . . . . . . . 1.3.2 Une durée de vie automatique . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3.3 La pile n’est pas infinie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4 Pourquoi l’allocation automatique ne suffit-elle pas toujours ? . . . . . . . . . . . . . 1.4.1 Retour au problème de la matrice . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.2 Une autre limite : la durée de vie . . . . . . . . . . . . . . . . . . . . . . . . . . 1.6 Première allocation dynamique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.6.1 Demander de la mémoire avec new . . . . . . . . . . . . . . . . . . . . . . . . 1.6.2 Conserver l’adresse obtenue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.6.3 Observons les deux objets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.7 Libérer la mémoire avec delete . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.8 Que devient le pointeur après delete ? . . . . . . . . . . . . . . . . . . . . . . . . . . 1.8.2 Que se passe-t-il si nous utilisons encore *p ? . . . . . . . . . . . . . . . . . . . 1.8.3 Le pointeur nul : nullptr . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.8.4 Déréférencer un pointeur nul . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.10 Allocation dynamique d’un tableau . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.10.1 Des objets contigus en mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . 1.10.2 Une allocation beaucoup plus importante . . . . . . . . . . . . . . . . . . . . . 1.10.3 Utiliser réellement la mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.10.4 Libérer un tableau dynamique . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.10.5 La mémoire libérée ne disparaît pas nécessairement immédiatement . . . . . . 1.10.6 Réutilisation de la zone libérée . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.10.7 Pile et tas : deux gestions différentes de la mémoire . . . . . . . . . . . . . . . 1.11 Que se passe-t-il si une allocation échoue ? . . . . . . . . . . . . . . . . . . . . . . . . 1.11.1 Le comportement de new . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.12.1 Perdre l’adresse d’une allocation . . . . . . . . . . . . . . . . . . . . . . . . . . 1.12.2 Pourquoi est-ce un problème ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.12.3 Une fuite peut également être provoquée par une affectation . . . . . . . . . 1.12.4 La fuite peut rester invisible . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III L’allocation dynamique de mémoire
Accueil
L’allocation dynamique de mémoire Toute variable occupe de la mémoire Lorsqu’une variable est créée dans un programme, une certaine quantité de mémoire doit être réservée pour stocker sa valeur. Considérons par exemple : Ces trois variables n’occupent pas nécessairement la même quantité de mémoire. L’opérateur sizeof, déjà rencontré précédemment, permet de connaître la taille occupée par un type ou par une variable. std::cout << sizeof(a) << ’\n’; std::cout << sizeof(x) << ’\n’; std::cout << sizeof(lettre) << ’\n’; Sur une machine courante, on pourra par exemple obtenir : Cela signifie que, sur cette machine : 2026 – C++ Partie III L’allocation dynamique de mémoire double −→ 8 octets La taille de certains types dépend de la machine et du compilateur utilisés. Il ne faut donc pas considérer, par exemple, qu’un int occupe obligatoirement 4 octets sur toutes les architectures. L’opérateur sizeof permet précisément de demander cette information au compilateur. Mais connaître la quantité de mémoire occupée ne suffit pas. Cette mémoire se trouve quelque part. Nous savons déjà qu’il est possible d’obtenir l’adresse d’une variable grâce à l’opérateur &. 9 std::cout << « Adresse de a :  » << &a << ’\n’; 10 std::cout << « Adresse de x :  » << &x << ’\n’; L’exécution pourra produire, par exemple : Adresse de a : 0x16f3a2b4c 2 Adresse de x : 0x16f3a2b40 Ces valeurs ne doivent pas être apprises ni interprétées pour l’instant. Elles indiquent simplement que les variables a et x sont stockées à deux endroits déterminés de la mémoire accessible au programme. Une variable possède donc au moins deux caractéristiques importantes du point de vue • une taille, que l’on peut connaître avec sizeof ; • une adresse, que l’on peut obtenir avec l’opérateur &. Nous allons maintenant utiliser ces deux informations comme instruments d’observation. L’objectif de cette partie ne sera pas seulement d’apprendre à demander de la mémoire au programme. Nous allons essayer de voir ce qui se passe réellement lorsque de la mémoire est réservée, utilisée puis
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Observons la disposition des variables en mémoire Nous savons maintenant qu’une variable possède une taille et une adresse. Essayons de voir comment plusieurs variables sont disposées en mémoire. Considérons d’abord trois variables du même type : std::cout << « Taille d’un int :  » << sizeof(int) <<  » octets\n\n »; std::cout << « Adresse de a :  » << &a << ’\n’; std::cout << « Adresse de b :  » << &b << ’\n’; std::cout << « Adresse de c :  » << &c << ’\n’; Sur la machine utilisée pour cette expérience, nous obtenons : Taille d’un int : 4 octets Adresse de a : 0x16fdfee78 Adresse de b : 0x16fdfee74 5 Adresse de c : 0x16fdfee70 Les adresses diffèrent ici de 4 octets : Dans cette exécution, les trois variables ont donc été placées les unes à la suite des autres, chacune Les adresses sont écrites en hexadécimal. Il faut donc se rappeler que : Des variables de tailles différentes Modifions maintenant l’expérience :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire std::cout << sizeof(a) << ’\n’; std::cout << sizeof(b) << ’\n’; std::cout << sizeof(c) << ’\n’; std::cout << &a << ’\n’; std::cout << &b << ’\n’; std::cout << &c << ’\n’; Nous avons mesuré : Adresse de a : 0x16fdfee70 Adresse de b : 0x16fdfee6c Adresse de c : 0x16fdfee60 Cette fois, la différence entre les adresses de b et de c vaut : Pourtant, c n’occupe que 8 octets. Nous observons donc un espace de 4 octets qui n’appartient ni à b ni à c. Ce phénomène est lié à une contrainte importante imposée par la machine : l’alignement des données en mémoire. Certains types sont de préférence placés à des adresses respectant certaines frontières. Sur la machine utilisée ici, les objets de type double sont notamment alignés sur des adresses multiples de 8 octets. Le compilateur peut donc laisser certains octets inutilisés afin de respecter ces contraintes. L’ordre des types peut avoir une influence Essayons maintenant : Nous obtenons cette fois : Adresse de a : 0x16fdfee70 Adresse de b : 0x16fdfee68 Adresse de c : 0x16fdfee64 Les écarts sont exactement de 8 puis de 4 octets. Dans cette expérience, aucun espace supplémentaire n’apparaît donc entre les trois variables.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire • La valeur contenue dans une variable ne modifie pas la taille occupée par son type. • En revanche, la disposition en mémoire de données de types différents doit respecter certaines contraintes d’alignement. • Des espaces inutilisés peuvent donc apparaître entre certaines données. Les expériences précédentes montrent ce que notre compilateur a choiside faire lors de ces Le langage C++ ne garantit pas que des variables locales indépendantes seront placées les unes à la suite des autres en mémoire, ni qu’elles apparaîtront dans l’ordre de leur déclaration. Il ne faut donc pas déduire une règle générale du C++ à partir des adresses que nous venons Nous retrouverons plus tard la question de l’alignement lorsque nous étudierons la disposition des membres d’une structure ou d’une classe. Un pointeur est lui-même une variable std::cout << « Valeur de a :  » << a << ’\n’; std::cout << « Adresse de a :  » << &a << « \n\n »; std::cout << « Valeur de p :  » << p << ’\n’; std::cout << « Adresse de p :  » << &p << ’\n’; Sur notre machine, nous avons par exemple obtenu : Adresse de a : 0x16fdfee78 Valeur de p : 0x16fdfee78 Adresse de p : 0x16fdfee70 Nous constatons que : Le pointeur p contient donc l’adresse de a. Mais p est lui-même une variable donc il occupe de la mémoire et possède sa propre adresse, obtenue avec &p.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Il ne faut pas confondre : • p : la valeur contenue dans le pointeur, c’est-à-dire l’adresse de l’objet pointé ; • &p : l’adresse du pointeur lui-même. Quelle taille occupe un pointeur ? Mesurons maintenant la taille de plusieurs pointeurs : std::cout << sizeof(p) << ’\n’; std::cout << sizeof(q) << ’\n’; std::cout << sizeof(r) << ’\n’; Sur notre machine, nous obtenons : La taille du pointeur ne dépend donc pas ici de la taille de l’objet qu’il peut désigner. Un pointeur vers un char de 1 octet occupe lui-même 8 octets, tout comme un pointeur vers un double de 8 Cela s’explique simplement : le pointeur ne contient pas l’objet mais il contient une adresse permettant de retrouver cet objet. Un pointeur possède donc lui-même un coût en mémoire. Ce coût peut être important si un programme utilise un très grand nombre de pointeurs pour désigner de petits objets. Il devient en revanche négligeable lorsqu’un pointeur permet d’accéder à une grande quantité de données. Comme toujours, lorsqu’une consommation de mémoire devient importante, il est préférable de la mesurer plutôt que de la supposer. La durée de vie automatique et la pile Dans les expériences précédentes, nous avons observé où certaines variables étaient placées en mémoire. Une autre question est maintenant essentielle : Pendant combien de temps cette mémoire reste-t-elle utilisée ? Considérons la fonction suivante :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire std::cout << « Adresse de a :  » << &a << ’\n’; Sur notre machine, les trois appels successifs ont affiché la même adresse pour a. Lors du premier appel de fonction(), la variable locale a est créée. Lorsque l’exécution quitte la fonction, cette variable cesse d’exister et la mémoire qu’elle utilisait peut alors être réutilisée. Lors du deuxième appel, une nouvelle variable a est créée. Dans notre expérience, le programme a réutilisé le même emplacement mémoire. Il en a été de même lors du troisième appel. Le langage C++ ne garantit pas que la même adresse sera obtenue lors de chaque appel. C’est le comportement que nous avons observé avec notre programme et notre compilateur. Il illustre cependant un principe fondamental : la mémoire occupée par une variable locale peut être réutilisée lorsque la durée de vie de cette variable est terminée. Lorsque plusieurs appels existent simultanément Modifions maintenant notre expérience en utilisant une fonction récursive : void fonction(int n) std::cout << « n =  » << n adresse de a :  » << &a << ’\n’;
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Cette fois, nous avons obtenu : adresse de a : 0x16fdfee58 adresse de a : 0x16fdfee38 adresse de a : 0x16fdfee18 adresse de a : 0x16fdfedf8 La situation est différente de celle de l’expérience précédente. Lorsque fonction(3) appelle fonction(2), le premier appel n’est pas terminé. Sa variable locale a doit donc continuer à exister. Il en est de même lorsque fonction(2) appelle fonction(1), puis lorsque fonction(1) appelle fonction(0). Plusieurs appels de la même fonction existent donc simultanément, et chacun possède sa propre Pour permettre à une fonction d’en appeler une autre puis de reprendre son exécution lorsque cet appel est terminé, le programme doit conserver certaines informations qui sont organisées dans une zone de mémoire généralement appelée la pile, ou stack. Lorsqu’une fonction est appelée, un nouvel espace est utilisé sur cette pile pour conserver les informations nécessaires à cet appel. On parle généralement d’un cadre de pile, ou stack frame. Ce cadre peut notamment contenir des variables locales ainsi que des informations nécessaires au fonctionnement et au retour de la fonction. Lorsque la fonction se termine, son cadre de pile n’est plus nécessaire donc l’espace correspondant peut être réutilisé lors d’appels ultérieurs. Dans notre expérience récursive, nous avons mesuré un écart de : entre les adresses successives de a. sizeof(a) = 4 octets. Ces 32 octets ne représentent donc pas simplement la taille de a. Ils reflètent la disposition des différents cadres de pile produite par cette compilation. La taille exacte d’un cadre de pile n’est pas déterminée simplement par la somme des tailles des variables locales. Elle dépend notamment de la fonction, du compilateur, de l’architecture, des contraintes d’alignement et des optimisations utilisées. L’écart de 32 octets mesuré dans notre expérience ne constitue donc pas une règle générale. Une durée de vie automatique Une variable locale ordinaire possède généralement une durée de vie automatique. Sa création et sa destruction sont liées à l’exécution du bloc auquel elle appartient.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire // a n’existe plus ici Le programmeur n’a aucune opération particulière à effectuer pour libérer la mémoire associée à a car sa durée de vie est gérée automatiquement. • Les variables locales ordinaires ont une durée de vie automatique. • Lorsqu’une fonction est appelée, les informations nécessaires à son exécution sont généralement placées dans un cadre de pile. • Lorsque l’appel se termine, ce cadre n’est plus nécessaire et l’espace correspondant peut • Des appels de fonctions imbriqués nécessitent plusieurs cadres de pile simultanément. La pile n’est pas infinie La quantité de mémoire disponible pour la pile est limitée. Dans une fonction récursive, chaque nouvel appel nécessite généralement un nouveau cadre de pile. Si le nombre d’appels imbriqués devient trop important, la pile peut finir par manquer d’espace. Ce phénomène est appelé un dépassement de pile, ou stack overflow. Nous avions déjà rencontré cette difficulté lors de l’étude de la récursivité. Nous pouvons maintenant mieux comprendre son origine : chaque appel supplémentaire doit conserver son propre contexte tant que les appels précédents ne sont pas terminés. Sur un système disposant de ressources limitées, comme un microcontrôleur, la quantité de mémoire disponible impose donc une attention particulière à la profondeur des appels récursifs. Si la durée de vie automatique fonctionne si bien, pourquoi avons-nous besoin de l’allocation Pourquoi l’allocation automatique ne suffit-elle pas toujours ? La gestion automatique de la mémoire que nous venons d’observer est particulièrement pratique. Lorsque nous écrivons : le programme sait quelle quantité de mémoire est nécessaire :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire De même, si nous écrivons : double valeurs[100]; le nombre d’éléments est connu et le programme peut prévoir l’espace nécessaire pour le tableau. Supposons maintenant que le nombre de valeurs à stocker ne soit connu qu’au moment de l’exécution std::cout << « Combien de valeurs ? « ; Lorsque le programme est écrit et compilé, nous ne connaissons pas encore la valeur de n. Elle pourrait être : La quantité de mémoire nécessaire dépend donc d’une information qui n’existera qu’au moment de Retour au problème de la matrice C’est exactement la difficulté que nous avions rencontrée avec notre classe Matrice à la partie II du Si nous voulons pouvoir écrire : Matrice B(100, 200); La première matrice doit pouvoir contenir : coefficients, tandis que la seconde doit pouvoir en contenir : Si les coefficients sont de type double, les quantités de mémoire nécessaires sont donc très différentes. Pourtant A et B sont deux objets du même type : Nous avons donc besoin d’un mécanisme permettant de demander une certaine quantité de mémoire pendant l’exécution du programme.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire • La quantité de mémoire nécessaire à un programme n’est pas toujours connue lors de son écriture ou de sa compilation. • Certaines informations, comme le nombre d’éléments à stocker, peuvent n’être connues qu’au moment de l’exécution. Nous avons alors besoin de pouvoir réserver de la mémoire Une autre limite : la durée de vie La quantité de mémoire nécessaire n’est pas la seule difficulté. Nous avons vu qu’une variable locale ordinaire possède une durée de vie automatique. std::cout << a << ’\n’; La variable a est créée pendant l’exécution de la fonction et lorsque la fonction se termine, a cesse d’exister et la mémoire qu’elle utilisait peut être réutilisée. Cette gestion automatique est très pratique dans la plupart des cas. Mais supposons que nous voulions créer une donnée dans une fonction et continuer à l’utiliser après la fin de cette fonction. Nous nous heurtons alors à une difficulté : la durée de vie de la donnée ne doit plus nécessairement coïncider avec la durée de vie de la fonction qui l’a créée. Nous avons donc besoin d’un mécanisme permettant de créer une donnée dont la durée de vie pourra être contrôlée indépendamment du bloc dans lequel sa création a été demandée. L’allocation dynamique répond notamment à deux besoins : • réserver une quantité de mémoire déterminée au moment de l’exécution ; • permettre à certaines données d’avoir une durée de vie qui n’est pas directement imposée par le bloc dans lequel elles ont été créées. Nous avons observé que les variables locales ordinaires sont généralement gérées à l’aide de la pile. Cette organisation est particulièrement efficace lorsque la durée de vie des données suit naturellement celle des fonctions et des blocs qui les utilisent. Mais nous venons de rencontrer deux situations différentes : • la quantité de mémoire nécessaire n’est connue qu’au moment de l’exécution ;
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire • la durée de vie souhaitée d’une donnée ne correspond pas nécessairement à celle du bloc dans lequel elle est créée. Pour répondre à ces besoins, un programme peut utiliser une autre zone de son espace mémoire : le tas, appelé heap en anglais. Contrairement à la mémoire utilisée automatiquement pour les variables locales, la mémoire demandée dans le tas doit être explicitement obtenue pendant l’exécution du Cette mémoire reste réservée jusqu’à ce qu’elle soit explicitement libérée. Nous pouvons représenter très schématiquement la situation ainsi : espace mémoire du programme Cette représentation est volontairement simplifiée. Elle ne décrit pas précisément l’organisation réelle de la mémoire virtuelle d’un système moderne. Elle permet simplement de distinguer deux mécanismes de gestion de mémoire que nous allons maintenant Le tas n’est pas une immense variable ni un tableau que le programme pourrait parcourir Lorsqu’un programme a besoin de mémoire dynamique, il demande au système de gestion de mémoire de lui fournir une zone suffisamment grande. Le programme reçoit alors un moyen de retrouver cette zone. C’est ici que les pointeurs vont devenir indispensables. Première allocation dynamique Nous disposons maintenant des éléments nécessaires pour effectuer notre première allocation dynamique. Considérons l’instruction suivante :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire double* p = new double; Cette ligne est courte, mais plusieurs opérations importantes s’y produisent. Il est donc utile de l’examiner étape par étape. Demander de la mémoire avec new Commençons par la partie droite de l’instruction : L’opérateur new demande qu’une zone de mémoire suffisamment grande pour contenir un objet de type double soit réservée dynamiquement. Sur notre machine : sizeof(double) = 8 octets. Une zone permettant de stocker un double est donc obtenue dans la mémoire dynamique. Mais connaître la taille de cette zone ne suffit pas. Il faut également savoir où elle se trouve. L’opérateur new fournit donc l’adresse de la zone obtenue. Conserver l’adresse obtenue Revenons maintenant à l’instruction complète : double* p = new double; déclare un pointeur vers un double. L’adresse fournie par new double est placée dans ce pointeur. Nous pouvons donc représenter schématiquement l’opération ainsi : Deux objets distincts interviennent donc : • le double créé dynamiquement et désigné par p.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire double* p = new double; effectue deux opérations conceptuellement différentes : • new double obtient de la mémoire pour un double et fournit son adresse ; • cette adresse est conservée dans le pointeur p. Le pointeur et l’objet pointé sont deux objets différents. Observons les deux objets Nous pouvons maintenant reprendre nos instruments de mesure : double* p = new double; std::cout << « Valeur de p :  » << p << ’\n’; std::cout << « Adresse de p :  » << &p << ’\n’; std::cout << « Valeur de *p :  » << *p << ’\n’; Valeur de p : 0x1007b1ea0 Adresse de p : 0x16fdfee60 Program ended with exit code: 0 La valeur de p correspond à l’adresse du double obtenu dynamiquement. L’adresse &p correspond, quant à elle, à l’emplacement du pointeur lui-même 1 . ∗p=7.99 est la valeur de l’objet dynamique. Fig. 1.1 – Schéma, pile et tas. 1. Dans notre expérience, les adresses du pointeur local et de l’objet créé dynamiquement sont très éloignées. Cela est cohérent avec le fait qu’ils appartiennent à des régions de mémoire gérées différemment. La distance exacte entre ces régions dépend du système et de l’exécution.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Fig. 1.2 – Différentes valeurs obtenues. A part la valeur 7.99 les autres vont naturellement changés d’une machine à une autre. Libérer la mémoire avec delete Nous avons créé dynamiquement un objet de type double avec : double* p = new double; Contrairement à une variable locale ordinaire, l’objet créé par new ne disparaît pas simplement parce que l’exécution quitte le bloc dans lequel l’allocation a été demandée. Lorsque nous n’avons plus besoin de cet objet, nous devons libérer la mémoire qui lui avait été attribuée. Pour cela, le C++ fournit l’opérateur delete : L’opérateur delete libère l’objet dynamique dont l’adresse est contenue dans p. double* p = new double; std::cout << « Valeur de p :  » << p << ’\n’; std::cout << « Adresse de p :  » << &p << ’\n’; std::cout << « Valeur de *p :  » << *p << ’\n’; Après l’exécution de : le double créé dynamiquement n’existe plus. La mémoire qu’il occupait peut désormais être réutilisée. Que devient le pointeur après delete ? Considérons le programme suivant : double* p = new double; std::cout << « Valeur de p std::cout << « Adresse de p std::cout << « Valeur de *p
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire détruit l’objet dynamique désigné par p et libère la mémoire qui lui avait été attribuée. Elle ne détruit cependant pas le pointeur p lui-même. Le pointeur est toujours une variable locale et continue donc d’exister. Dans notre expérience, nous avons constaté que sa valeur n’avait même pas changé après l’exécution de delete. Avant et après delete, p contenait encore la même adresse. Mais cette adresse ne désigne désormais plus un objet que nous avons le droit d’utiliser. Un pointeur qui contient l’adresse d’un objet qui n’existe plus est appelé un pointeur pendant, ou La situation peut être représentée schématiquement ainsi : | adresse | ———–> objet détruit Le pointeur existe toujours, mais l’adresse qu’il contient ne doit plus être utilisée pour accéder à Que se passe-t-il si nous utilisons encore *p ? Nous avons volontairement effectué l’expérience suivante : std::cout << *p << ’\n’; Lors de notre exécution, le programme a affiché une valeur telle que :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Cette valeur 2 ne possède aucune signification particulière. Après delete, l’objet désigné auparavant par p n’existe plus. dans cette situation provoque un comportement indéfini. Cela signifie que le langage C++ ne garantit plus le comportement du programme. Celui-ci peut afficher une valeur apparemment quelconque, sembler fonctionner normalement, s’arrêter brutalement ou présenter un autre comportement. Une erreur de gestion de mémoire ne provoque donc pas nécessairement une erreur immédiatement visible. C’est précisément ce qui rend certaines erreurs liées aux pointeurs particulièrement difficiles à Le pointeur nul : nullptr Lorsque le pointeur ne doit plus désigner aucun objet, nous pouvons lui affecter la valeur spéciale : Nous pouvons donc écrire : La première instruction détruit l’objet dynamique. La seconde modifie le pointeur afin d’indiquer explicitement qu’il ne désigne plus aucun objet. Il est important de comprendre que : ne libère aucune mémoire. La mémoire a déjà été libérée par : L’affectation de nullptr sert uniquement à ne plus conserver dans p l’adresse devenue invalide. Déréférencer un pointeur nul 2. La valeur affichée est extrêmement petite mais reste représentable par un double.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire std::cout << *p << ’\n’; l’exécution s’arrête avec l’erreur : Thread 1: EXC_BAD_ACCESS Le pointeur ne désignait plus aucun objet et nous avons pourtant essayé d’accéder à l’objet pointé Déréférencer un pointeur nul constitue également un comportement indéfini. L’erreur EXC_BAD_ACCESS est le comportement que nous avons observé dans notre expérience ; elle n’est pas imposée par le langage C++. Nous pouvons maintenant résumer le cycle élémentaire d’une allocation dynamique : double* p = new double; // utilisation de *p Les différentes étapes sont : allocation −→ utilisation −→ libération −→ pointeur nul Dans l’exemple précédent : • p contient l’adresse de l’objet dynamique ; • &p représente l’adresse du pointeur lui-même ; • *p permet d’accéder à l’objet désigné par le pointeur ; • delete p détruit l’objet dynamique désigné par p, mais ne détruit pas le pointeur ; • après delete, le pointeur contient encore l’ancienne adresse et devient un pointeur • p = nullptr indique explicitement que le pointeur ne désigne plus aucun objet. Allocation dynamique d’un tableau L’opérateur new permet d’allouer dynamiquement un objet unique mais il est également possible de demander de la mémoire pour plusieurs objets du même type.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Pour cela, nous utilisons la notation : double* p = new double[n]; L’opérateur new[] réserve une zone permettant de contenir n objets de type double dont l’adresse du premier élément est conservée dans le pointeur p. Des objets contigus en mémoire Commençons par une petite allocation : double* p = new double[n]; std::cout << « &p[0] :  » << &p[0] << ’\n’; std::cout << « &p[1] :  » << &p[1] << ’\n’; std::cout << « &p[2] :  » << &p[2] << ’\n’; std::cout << « &p[3] :  » << &p[3] << ’\n’; std::cout << « &p[4] :  » << &p[4] << ’\n’; Dans notre expérience, nous avons obtenu des adresses de la forme : Nous constatons que deux éléments successifs sont séparés de : Or nous avions mesuré : Les éléments du tableau sont donc placés les uns à la suite des autres dans une zone contiguë de Une allocation beaucoup plus importante Effectuons maintenant une expérience à une autre échelle : std::size_t n = 100’000’000; double* p = new double[n]; Nous demandons de la place pour cent millions de double, donc la quantité de mémoire nécessaire aux éléments est : 100 000 000 × 8 = 800 000 000 octets. Cela représente environ :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Immédiatement après l’allocation, sans avoir encore écrit dans le tableau, le Moniteur d’activité de notre système indiquait pourtant seulement environ : pour notre processus. Utiliser réellement la mémoire Nous avons ensuite rempli l’ensemble du tableau : for (std::size_t i = 0; i < n; i++) Après cette opération, le Moniteur d’activité indiquait environ : Cette valeur est très proche des quelque 763 Mio nécessaires aux cent millions de double, auxquels s’ajoute la mémoire utilisée par le reste du processus. L’expérience montre une propriété importante des systèmes modernes utilisant la mémoire virtuelle : réserver un espace d’adressage et utiliser effectivement la mémoire correspondante ne sont pas exactement la même chose. La simple allocation d’une grande zone ne conduit donc pas nécessairement à une consommation immédiatement équivalente de mémoire physique. Libérer un tableau dynamique Un tableau créé avec new[] doit être libéré avec delete[] : Il faut donc respecter les deux associations : les objets du tableau dynamique n’existent plus et la zone qui leur avait été attribuée redevient disponible pour l’allocateur. Comme précédemment, nous avons ensuite écrit : afin que le pointeur ne conserve pas l’adresse devenue invalide.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire La mémoire libérée ne disparaît pas nécessairement immédiatement Après delete[], nous avons observé que le Moniteur d’activité continuait à indiquer une consommation voisine de Cela ne signifie pas que le tableau existe encore. La mémoire a été rendue disponible pour l’allocateur du processus, mais celui-ci peut conserver cette mémoire afin de satisfaire efficacement de futures allocations. Le compteur fourni par le système d’exploitation ne constitue donc pas une mesure directe de la mémoire occupée par les objets C++ actuellement vivants. Réutilisation de la zone libérée Nous avons finalement effectué une nouvelle allocation de même taille : double* q = new double[n]; std::cout << « &q[0] :  » << &q[0] << ’\n’; Avant la libération du premier tableau, nous avions mesuré : Après sa destruction, la nouvelle allocation a donné : Dans notre expérience, l’allocateur a donc réutilisé exactement la même zone mémoire. Le C++ ne garantit pas qu’une nouvelle allocation recevra la même adresse qu’une allocation précédemment libérée. C’est le comportement que nous avons observé dans cette expérience. Il montre cependant très concrètement que la mémoire libérée par delete[] peut être réutilisée pour satisfaire une nouvelle allocation.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire • L’instruction : double* p = new double[n]; crée dynamiquement un tableau de n objets de type double. Les éléments sont stockés de manière contiguë et p contient l’adresse du premier élément. • Un tableau créé avec new[] doit être détruit avec : • Après sa destruction, la mémoire qu’il utilisait redevient disponible pour de nouvelles ne libère pas la mémoire : elle indique simplement que le pointeur p ne désigne plus Pile et tas : deux gestions différentes de la mémoire Nous avons maintenant rencontré deux manières très différentes de gérer la mémoire pendant l’exécution d’un programme. La pile (stack) est notamment utilisée pour gérer les appels de fonctions et, généralement, leurs variables locales. Cette gestion est automatique : lorsqu’une fonction se termine, les informations associées à son appel ne sont plus nécessaires et l’espace correspondant peut être réutilisé. Le programmeur n’a donc pas à libérer explicitement cette mémoire. Le tas (heap) permet au contraire d’obtenir de la mémoire dynamiquement pendant l’exécution du programme. Avec new ou new[], nous pouvons demander une quantité de mémoire qui n’était pas nécessairement connue lors de la compilation. Cette mémoire n’est pas libérée automatiquement à la fin du bloc dans lequel l’allocation a été effectuée. Sa libération doit être provoquée explicitement avec delete ou delete[]. La pile est donc particulièrement adaptée aux objets dont la durée de vie suit naturellement l’exécution des fonctions, tandis que le tas permet de contrôler plus librement la taille et la durée de vie des objets créés pendant l’exécution. • La pile est gérée automatiquement et suit principalement la logique des appels de fonctions. • Le tas permet une allocation dynamique dont la taille et la durée de vie peuvent être déterminées pendant l’exécution. • Ces deux mécanismes utilisent la mémoire de manière différente et répondent à des besoins différents.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Que se passe-t-il si une allocation échoue ? Jusqu’à présent, toutes nos demandes de mémoire dynamique ont réussi. Nous avons par exemple double* p = new double[n]; et l’opérateur new[] nous a fourni une zone suffisamment grande pour contenir les n objets demandés. Mais la quantité de mémoire disponible n’est évidemment pas infinie. Il est donc possible de demander une allocation que le système ne peut pas satisfaire. Le comportement de new Dans sa forme habituelle, si new ne parvient pas à effectuer l’allocation demandée, il ne retourne normalement pas un pointeur nul. Il signale l’échec en lançant une exception de type : Cette exception est définie dans l’en-tête : Si cette exception n’est pas traitée par le programme, celui-ci se termine. Nous étudierons les exceptions plus en détail ultérieurement. Pour le moment, il suffit de retenir qu’une allocation dynamique peut échouer et que l’opérateur new dispose d’un mécanisme permettant de signaler cet échec. • La mémoire dynamique disponible n’est pas infinie. • Une instruction telle que : double* p = new double[n]; n’est donc pas assurée de réussir. • Dans sa forme habituelle, lorsqu’une allocation ne peut pas être effectuée, new signale l’échec en lançant une exception de type std::bad_alloc. Les fuites de mémoire Nous avons vu qu’une allocation dynamique effectuée avec : double* p = new double[n]; doit être libérée lorsqu’elle n’est plus nécessaire :
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire Mais que se passe-t-il si cette instruction est oubliée ? Perdre l’adresse d’une allocation Considérons une fonction très simple : std::size_t n = 1000; double* p = new double[n]; // utilisation du tableau Le pointeur p est une variable locale. Lorsque l’exécution quitte la fonction, sa durée de vie se termine automatiquement. Le tableau créé par : possède cependant une durée de vie différente. Il ne disparaît pas simplement parce que le pointeur Nous avons donc la situation suivante : Pendant l’exécution de la fonction : | adresse | ———–> [ double ][ double ] … [ double ] Après la fin de la fonction : [ double ][ double ] … [ double ] ne permet de retrouver La mémoire dynamique est toujours allouée, mais nous avons perdu l’adresse permettant de la retrouver. Cette situation est appelée une fuite de mémoire, ou memory leak. Pourquoi est-ce un problème ? Une petite fuite isolée peut sembler sans importance. Mais supposons que la fonction précédente soit appelée de nombreuses fois : for (int i = 0; i < 10000; i++)
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire À chaque appel, une nouvelle zone de mémoire est allouée. Comme aucune de ces zones n’est libérée, la quantité de mémoire inutilisable par le programme augmente progressivement. Le programme peut ainsi consommer de plus en plus de mémoire alors même qu’il n’est plus capable d’accéder aux données responsables de cette consommation. Sur un système disposant de peu de mémoire, ce phénomène peut devenir rapidement critique. Une fuite peut également être provoquée par une affectation Il n’est pas nécessaire qu’un pointeur disparaisse pour perdre une allocation. Considérons : double* p = new double[1000]; La seconde instruction ne libère pas le tableau. Elle remplace simplement l’adresse contenue dans p par nullptr. L’adresse du tableau est alors perdue. Il n’est donc plus possible d’écrire correctement : pour libérer l’ancienne allocation, puisque p ne contient plus son adresse. ne libère jamais à elle seule la mémoire dynamique désignée auparavant par p. Lorsque p désigne encore une allocation dont nous sommes responsables, il faut d’abord la libérer : La fuite peut rester invisible Une fuite de mémoire ne provoque pas nécessairement une erreur immédiate. Le programme peut continuer à fonctionner normalement pendant un certain temps. Le problème apparaît progressivement lorsque les allocations perdues s’accumulent et consomment une partie croissante de la mémoire C’est pourquoi les fuites de mémoire peuvent être particulièrement difficiles à détecter dans les programmes de longue durée.
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire • Une fuite de mémoire se produit lorsqu’une zone de mémoire dynamique reste allouée alors que le programme a perdu le moyen de la retrouver et donc de la libérer. double* p = new double[1000]; // l’adresse est perdue provoque une fuite de mémoire. double* p = new double[1000]; // utilisation du tableau libère correctement la mémoire avant de faire perdre au pointeur l’adresse de l’ancienne • Une fuite de mémoire persiste pendant la vie du processus. Lorsque le processus se termine, le système d’exploitation récupère sa mémoire. Dans ce chapitre, nous avons étudié les mécanismes fondamentaux de l’allocation dynamique de mémoire. Lorsqu’une variable possède une durée de vie automatique, sa création et sa destruction sont prises en charge automatiquement. La mémoire dynamique permet au contraire de réserver, pendant l’exécution du programme, une quantité de mémoire qui peut dépendre des besoins du permet de créer dynamiquement un objet et retourne son adresse. double* p = new double; Le pointeur p contient alors l’adresse de l’objet créé dynamiquement. Lorsque cet objet n’est plus nécessaire, sa mémoire doit être libérée : Pour plusieurs objets contigus, nous utilisons : double* p = new double[n];
Accueil
2026 – C++ Partie III L’allocation dynamique de mémoire et la libération correspondante doit être effectuée avec : Il faut donc respecter les associations : new[] ←→ delete[] Après delete ou delete[], l’objet dynamique n’existe plus. Le pointeur, en revanche, peut continuer à exister et conserver l’ancienne adresse : il devient alors un pointeur pendant. permet d’indiquer explicitement que le pointeur ne désigne plus aucun objet. Enfin, oublier de libérer une allocation dynamique avant d’en perdre l’adresse provoque une fuite de mémoire. Cette fuite peut rester invisible pendant longtemps et devenir problématique lorsqu’elle se • La mémoire dynamique est obtenue pendant l’exécution du programme. • new crée dynamiquement un objet et retourne son adresse. • new[] permet de créer dynamiquement plusieurs objets contigus. • Toute allocation effectuée avec new doit être associée à une libération avec delete. • Toute allocation effectuée avec new[] doit être associée à une libération avec delete[]. • delete et delete[] détruisent les objets dynamiques concernés, mais ne détruisent pas le pointeur lui-même. • Un pointeur qui conserve l’adresse d’un objet détruit est un pointeur pendant. • nullptr permet de représenter explicitement un pointeur qui ne désigne aucun objet. • Perdre l’adresse d’une allocation sans l’avoir libérée provoque une fuite de mémoire. • Une allocation dynamique peut échouer lorsque la demande ne peut pas être satisfaite.
Accueil
P3-Chap-01- L’allocation dynamique de mémoire
Accueil
Termes à ajouter au glossaire

Chapitre 2 - La copie des objets

Chapitre 2 – La copie des objets

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
2 La copie des objets 2.1 Que signifie copier un objet ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3 Que copie réellement le compilateur ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4 Une classe contenant un pointeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III
Accueil
Que signifie copier un objet ? Nous avons déjà rencontré à de nombreuses reprises la copie de variables simples. Par exemple : Après ces instructions, les variables a et b contiennent toutes les deux la valeur 5, mais elles constituent deux variables distinctes. Modifier ensuite b ne modifie pas a : La valeur de a a donc servi à initialiser b, mais les deux variables sont ensuite indépendantes. Considérons maintenant une classe très simple représentant un point du plan : Point(double xInitial, double yInitial); double getX() const; double getY() const; void setX(double nouvelleValeur); void setY(double nouvelleValeur); 2026 – C++ Partie III Son constructeur initialise simplement les deux coordonnées : Point::Point(double xInitial, double yInitial) Créons maintenant un objet A : et utilisons cet objet pour en créer un second : Nous n’avons pourtant défini aucun constructeur permettant explicitement de construire B à partir Le programme est néanmoins parfaitement valide. Nous avons uniquement défini le constructeur Point(double xInitial, double yInitial); Comment le compilateur peut-il alors exécuter l’instruction suivante ? Le compilateur C++ peut générer automatiquement certaines fonctions spéciales nécessaires au fonctionnement d’une classe. Parmi celles-ci se trouve le constructeur de copie. Lorsque nous écrivons un nouvel objet B est construit à partir de l’objet A. Dans notre classe Point, le constructeur de copie généré automatiquement effectue une copie des membres de l’objet. Nous obtenons donc conceptuellement : x = 2.0 −→ x = 2.0 y = 5.0 −→ y = 5.0 Les deux objets possèdent les mêmes valeurs immédiatement après la copie, mais restent deux objets Nous pouvons le vérifier en modifiant uniquement l’objet B :
Accueil
2026 – C++ Partie III std::cout << A.getX() << std::endl; std::cout << B.getX() << std::endl; Le programme affiche : La modification de B n’a donc eu aucun effet sur A. • Lorsqu’un nouvel objet est construit à partir d’un objet existant de la même classe, une opération de copie est effectuée. • Si aucun constructeur de copie particulier n’a été défini par le programmeur, le compilateur peut en générer un automatiquement. • Pour une classe simple comme Point, cette copie des membres produit deux objets indépendants possédant initialement les mêmes valeurs. Les deux écritures suivantes ne correspondent pas à la même opération : construit un nouvel objet B à partir de A. suppose que B existe déjà et lui affecte les valeurs provenant de A. Nous distinguerons donc soigneusement, dans la suite de ce chapitre, construction par copie et affectation par copie. Que copie réellement le compilateur ? Dans le cas de la classe Point, la copie automatique semble produire exactement le résultat souhaité. Mais cette situation est-elle toujours aussi simple ? Pour répondre à cette question, nous devons examiner plus précisément ce que signifie la copie des membres d’un objet. Une classe contenant un pointeur Pour comprendre ce qui peut rendre la copie d’un objet plus délicate, considérons maintenant une classe volontairement très simple.
Accueil
2026 – C++ Partie III La classe EntierDynamique représente une valeur entière. Cependant, contrairement à une classe qui contiendrait directement un membre de type int, la valeur sera placée dans une zone de mémoire allouée dynamiquement. La classe contient donc un pointeur : class EntierDynamique EntierDynamique(int valeurInitiale); int getValeur() const; void setValeur(int nouvelleValeur); Lors de la construction de l’objet, une zone mémoire permettant de stocker un int est réservée dynamiquement. L’adresse de cette zone est conservée dans le membre valeur. EntierDynamique::EntierDynamique(int valeurInitiale) *valeur = valeurInitiale; Lorsque l’objet disparaît, la zone mémoire qu’il possède doit être libérée. EntierDynamique::~EntierDynamique() La lecture et la modification de l’entier nécessitent un déréférencement du pointeur : int EntierDynamique::getValeur() const void EntierDynamique::setValeur(int nouvelleValeur) *valeur = nouvelleValeur;
Accueil
2026 – C++ Partie III Considérons le programme suivant : EntierDynamique A(5); EntierDynamique B = A; std::cout << A.getValeur() << std::endl; std::cout << B.getValeur() << std::endl; Quelles valeurs pensez-vous que le programme affichera ? Les objets A et B sont-ils indépendants comme l’étaient les deux objets de la classe Point ? Réponse : L’exécution s’arrête avec une erreur parce que les deux objets A et B possèdent après la copie la même adresse dans leur membre valeur. A la fin de main le programme essaie de détruire deux fois la même variable valeur. Pour remédier à cette erreur on va construire un nouveau constructeur qui aura comme paramètre un objet de type EntierDynamique. Ce constructeur est : EntierDynamique::EntierDynamique( const EntierDynamique& autre) *valeur = *autre.valeur; valeur int réserve un nouvel int et place son adresse dans le pointeur valeur du nouvel objet. ∗valeur = ∗autre.valeur; fait deux référencements.
Accueil
2026 – C++ Partie III • La classe EntierDynamique possède maintenant deux constructeurs de signatures différentes : EntierDynamique(int valeurInitiale); EntierDynamique(const EntierDynamique& autre); Il s’agit d’une surcharge de constructeurs. Le constructeur appelé dépend du type de l’argument utilisé lors de la construction de l’objet. EntierDynamique A(5); appelle le constructeur recevant un int. Celui-ci réserve dynamiquement une zone mémoire permettant de stocker un entier et conserve son adresse dans le membre valeur. EntierDynamique B = A; construit un nouvel objet à partir d’un objet existant de la même classe. Elle fait appel au constructeur de copie : EntierDynamique(const EntierDynamique& autre); • Le paramètre autre est passé par référence constante. La référence évite de devoir effectuer une copie pour transmettre l’objet au constructeur de copie, et const garantit que l’objet source ne sera pas modifié. • Dans notre constructeur de copie, EntierDynamique::EntierDynamique( const EntierDynamique& autre) *valeur = *autre.valeur; une nouvelle zone mémoire est réservée pour le nouvel objet. La valeur contenue dans la zone mémoire de l’objet source est ensuite copiée dans cette nouvelle zone. • Les deux objets possèdent donc des allocations différentes contenant initialement la même valeur. Il s’agit d’une copie profonde (deep copy ). • Une méthode d’une classe peut accéder aux membres privés d’un autre objet de la même classe. L’expression est donc parfaitement valide même si valeur est déclaré private. • Lors d’une copie profonde, il faut distinguer soigneusement la valeur copiée de l’adresse. Les deux objets contiennent initialement la même valeur, mais leurs pointeurs contiennent des adresses différentes.
Accueil
2026 – C++ Partie III L’affectation par copie Nous avons étudié la construction d’un nouvel objet à partir d’un objet existant : EntierDynamique A(5); EntierDynamique B = A; Au moment de l’instruction EntierDynamique B = A; l’objet B n’existe pas encore donc il doit être construit, c’est donc le constructeur de copie qui est La situation est différente dans le programme suivant : EntierDynamique A(5); EntierDynamique B(12); Lorsque l’instruction est exécutée, les deux objets existent déjà. Il ne s’agit donc plus de construire B, mais de lui affecter Cette opération est appelée affectation par copie. Avant l’affectation, nous avons deux objets indépendants : B.valeur −→ [ 12 ] L’objet B possède donc déjà sa propre zone de mémoire dynamique. Lorsqu’une méthode non statique est appelée, elle agit nécessairement sur un objet particulier. Considérons par exemple : EntierDynamique A(5); Lors de l’exécution de la méthode setValeur(), celle-ci doit savoir sur quel objet elle doit agir. Le C++ met pour cela automatiquement à sa disposition un pointeur particulier nommé this. Ce pointeur contient l’adresse de l’objet sur lequel la méthode est actuellement exécutée. void EntierDynamique::setValeur(int nouvelleValeur) *valeur = nouvelleValeur; pourrait également être écrite :
Accueil
2026 – C++ Partie III void EntierDynamique::setValeur(int nouvelleValeur) *(this->valeur) = nouvelleValeur; Dans la première écriture, l’utilisation de this est simplement implicite. Le mot-clé this est un pointeur. Si une méthode est appelée par l’objet A, nous pouvons conceptuellement écrire : this = adresse de A. désigne donc l’adresse de l’objet courant. Comme pour tout pointeur, l’opérateur de déréférencement permet d’accéder à l’objet situé à cette désigne donc l’objet courant lui-même. Puisque this est un pointeur, l’accès à un membre de l’objet courant peut s’écrire avec l’opérateur Cette expression désigne le membre valeur de l’objet pointé par this. Elle est équivalente à : Les parenthèses sont nécessaires car l’opérateur . possède une priorité supérieure à l’opérateur de déréférencement ∗. • Dans toute méthode non statique, this est un pointeur vers l’objet sur lequel la méthode • this représente donc l’adresse de l’objet courant, tandis que ∗this représente l’objet permet d’accéder à un membre de l’objet courant. • Dans la plupart des méthodes, l’utilisation explicite de this n’est pas nécessaire. • Le pointeur this devient particulièrement utile lorsqu’il est nécessaire de connaître ou de comparer l’adresse de l’objet courant.
Accueil
2026 – C++ Partie III EntierDynamique& EntierDynamique::operator=( const EntierDynamique& autre) *valeur = *autre.valeur; Ce code permet d’effectuer A=A ! • Le constructeur de copie intervient lorsqu’un nouvel objet est créé à partir d’un objet • L’opérateur d’affectation intervient lorsque l’objet destination existe déjà. • Une affectation profonde doit libérer l’ancienne ressource avant d’en créer une nouvelle. permet d’éviter les problèmes liés à l’auto-affectation. • L’opérateur d’affectation retourne généralement ∗this, c’est-à-dire l’objet courant.
Accueil
P3-Chap-02- La copie des objets
Accueil
Termes à ajouter au glossaire

Chapitre 3 - Les entrées et sorties : flux, console et fichiers

Chapitre 3 – Les entrées et sorties : flux, console et fichiers

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }

Chapitre 4 - Sorties CSV et gnuplot

Chapitre 4 – Sorties CSV et gnuplot

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }

Chapitre 5 - Une alternative à gnuplot : le format SVG

Chapitre 5 – Une alternative à gnuplot : le format SVG

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }

Partie IV : C++ moderne et RAII

C++ moderne et RAII

Chapitre 1 - RAII et gestion moderne des ressources

Chapitre 1 – RAII et gestion moderne des ressources

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
1 RAII et gestion moderne des ressources 1.1 Pourquoi revenir sur la gestion des ressources ? . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3.3 RAII ne signifie pas “tout mettre sur la pile” . . . . . . . . . . . . . . . . . . . . . . 1.4 RAII et les différentes sorties d’une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.1 Sortie anticipée avec return . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.6 Notre première classe RAII est-elle vraiment sûre ? . . . . . . . . . . . . . . . . . . . . . . . . 1.6.2 Deux propriétaires pour une seule ressource . . . . . . . . . . . . . . . . . . . . . . . 1.8 Vers la gestion moderne des ressources . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III RAII et gestion moderne des ressources
Accueil
RAII et gestion moderne des ressources Pourquoi revenir sur la gestion des ressources ? Dans la partie consacrée à l’allocation dynamique, nous avons appris à demander explicitement de la mémoire pendant l’exécution d’un programme : double* p = new double[1000]; Cette mémoire doit ensuite être libérée explicitement : Cette manière de procéder nous a permis de comprendre ce qui se passe réellement lors d’une allocation dynamique : une zone de mémoire est réservée, son adresse est conservée dans un pointeur, puis cette zone doit être rendue au système lorsqu’elle n’est plus nécessaire. Mais, le problème est que cette gestion repose entièrement sur le programmeur. Il faut penser à appeler delete ou delete[] au bon moment et, surtout, il faut s’assurer que cette libération aura lieu quel que soit le chemin suivi par le programme. Considérons par exemple : double* p = new double[1000]; // Quelques calculs… // Autres calculs… Si la condition erreur est vraie, la fonction se termine immédiatement et l’instruction n’est jamais exécutée. 2026 – C++ Partie III RAII et gestion moderne des ressources La mémoire réservée avec new[] n’est donc pas libérée ! Nous avons créé une fuite de mémoire. Plus un programme devient complexe, plus il devient difficile de garantir manuellement que chaque ressource acquise sera correctement libérée c’est pour ceci que le C++ propose une manière beaucoup plus robuste La durée de vie d’une ressource peut être liée à la durée de vie d’un objet. C’est le principe fondamental appelé « RAII 1 . Qu’est-ce qu’une ressource ? Avant de définir précisément RAII, il faut préciser ce que l’on entend par le mot ressource. Dans les exemples précédents, la ressource était une zone de mémoire obtenue par une allocation dynamique, mais en programmation, une ressource peut prendre de nombreuses formes. Il peut s’agir par exemple, • d’une zone de mémoire allouée dynamiquement ; • d’un fichier ouvert ; • d’une connexion réseau ; • d’un verrou (mutex ) ; • d’une connexion à une base de données ; • d’un objet ou d’un périphérique fourni par le système d’exploitation ; • plus généralement, de quelque chose qui doit être acquis, utilisé puis libéré. Une ressource possède donc généralement un cycle de vie que l’on peut schématiser ainsi : acquisition −→ utilisation −→ libération Pour une allocation dynamique : new −→ utilisation de la mémoire −→ delete ouverture −→ lecture ou écriture −→ fermeture Dans les deux cas, le problème est fondamentalement le même : une ressource acquise doit être correctement libérée. RAII est l’acronyme de : Resource Acquisition Is Initialization que l’on peut traduire littéralement par : L’acquisition d’une ressource est l’initialisation. Cette traduction ne permet cependant pas de comprendre immédiatement l’idée essentielle de RAII, on en est même assez loin ! Une formulation beaucoup plus parlante est la suivante : 1. Resource Acquisition Is Initialization » » ou « l’acquisition d’une ressource est l’initialisation ».
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources La durée de vie d’une ressource est liée à la durée de vie d’un objet qui en est responsable. L’objet acquiert la ressource lors de son initialisation, généralement dans son constructeur et conserve ensuite cette ressource pendant sa durée de vie, jusqu’à destruction de l’objet, son destructeur libère alors la Le mécanisme peut donc être représenté ainsi : construction de l’objet acquisition de la ressource utilisation de la ressource destruction de l’objet libération de la ressource L’intérêt fondamental vient du fait que la destruction des objets automatiques est elle-même gérée par le langage et dès qu’un objet local arrive à la fin de sa portée, son destructeur est automatiquement appelé 2 . Il devient donc possible de faire dépendre la libération d’une ressource d’un mécanisme que le langage garantit déjà : la destruction de l’objet qui la possède. Construisons volontairement une classe très simple qui possède un tableau dynamique : Tableau(std::size_t n) donnees = new double[n]; Le constructeur acquiert la ressource : donnees = new double[n]; et le destructeur la libère : 2. RAII ne doit pas être confondu avec un garbage collector (ramasse-miettes). Avec RAII, la libération d’une ressource est déterministe : elle intervient lorsque l’objet qui la possède est détruit, généralement à la fin de sa portée. Un garbage collector, utilisé notamment par Java, recherche périodiquement les objets devenus inaccessibles et récupère leur mémoire ; le moment exact de cette récupération n’est généralement pas déterminé par la portée de l’objet. RAII peut en outre gérer d’autres ressources que la mémoire, comme des fichiers, des sockets ou des verrous.
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources Nous pouvons maintenant écrire : L’objet t est créé lorsque l’exécution entre dans la fonction et son constructeur réserve la mémoire nécessaire au tableau. Lorsque l’exécution quitte la fonction, l’objet t arrive à la fin de sa durée de vie, son destructeur est alors automatiquement appelé et celui-ci exécute : La mémoire est donc libérée sans que la fonction calcul() ait elle-même besoin d’appeler delete[]. RAII ne signifie pas simplement « Mettre delete dans un destructeur ». RAII est un principe plus général. Un objet devient responsable d’une ressource et la durée de vie de cette ressource est liée à celle de l’objet. On peut résumer cette relation ainsi : objet propriétaire −→ ressource durée de vie de l’objet −→ durée de vie de la ressource Lorsque l’objet disparaît, la ressource qu’il possède est automatiquement libérée. RAII ne signifie pas “tout mettre sur la pile” Cette distinction est importante. l’objet t possède une durée de vie automatique, mais la mémoire qu’il contrôle est toujours obtenue donnees = new double[n]; Cette mémoire se trouve donc toujours dans la zone utilisée pour les allocations dynamiques et RAII ne supprime pas nécessairement l’allocation dynamique, mais supprime la nécessité pour le code qui utilise l’objet de gérer la libération de cette allocation.
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources Le principe RAII est antérieur au C++ moderne et existait déjà bien avant C++11. Le C++ moderne a cependant introduit ou généralisé des outils qui permettent d’appliquer ce principe beaucoup plus facilement et de manière beaucoup plus sûre. Parmi eux, nous rencontrerons notamment : • std::unique_ptr ; • std::shared_ptr ; • les conteneurs de la bibliothèque standard tels que std::vector. Ces outils permettent dans de très nombreux cas de ne plus écrire directement : Il est cependant important d’avoir étudié ces mécanismes auparavant car les outils modernes ne font pas disparaître la gestion des ressources mais permettent de la confier à des objets conçus pour l’effectuer • RAII (Resource Acquisition Is Initialization) est un principe de conception fondamental du • Une ressource est confiée à un objet qui en devient responsable. • L’acquisition de la ressource est liée à l’initialisation de l’objet et sa libération à la destruction • Ainsi, lorsque l’objet arrive à la fin de sa durée de vie, son destructeur assure automatiquement la libération de la ressource. • RAII ne concerne pas uniquement la mémoire dynamique, le même principe peut être utilisé pour les fichiers, les connexions réseau, les verrous et de nombreuses autres ressources. RAII et les différentes sorties d’une fonction L’intérêt de RAII devient particulièrement évident lorsqu’une fonction peut se terminer de plusieurs manières. Reprenons notre classe Tableau, qui acquiert une zone de mémoire dans son constructeur et la libère dans son destructeur et considérons d’abord le cas le plus simple :
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources Lorsque l’exécution atteint l’accolade fermante de la fonction, l’objet local t arrive à la fin de sa durée de vie et son destructeur est automatiquement appelé, ce qui libère la mémoire. Mais une fonction ne se termine pas nécessairement en atteignant sa dernière accolade. Sortie anticipée avec return Considérons maintenant le programme // Premiers calculs… // Autres calculs… Si erreur est vraie, l’instruction return provoque une sortie immédiate de la fonction. Cependant, avant de quitter la fonction, le C++ détruit les objets locaux dont la durée de vie doit se terminer et par conséquent, le destructeur de t est appelé et celui-ci lance : La mémoire est ainsi correctement libérée, même si l’exécution n’atteint jamais la dernière accolade de la fonction. Comparons cela avec une gestion entièrement manuelle : double∗ p = new double[1000]; // Premiers calculs… // Autres calculs… Dans ce deuxième programme, le return empêche l’exécution de La mémoire n’est donc pas libérée. Avec RAII, la libération de la ressource ne dépend plus de l’exécution explicite d’une instruction placée à un endroit précis du programme mais de la durée de vie de l’objet qui possède cette ressource.
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources • Avec RAII, une sortie anticipée d’une fonction par return ne provoque pas la perte des ressources appartenant aux objets locaux. • Lorsque ces objets arrivent à la fin de leur durée de vie, leurs destructeurs sont automatiquement appelés et libèrent les ressources qu’ils possèdent. RAII et les exceptions Une fonction peut également être interrompue par une exception, par exemple : // Calcul susceptible de provoquer une exception… Si une exception est déclenchée pendant l’exécution de la fonction, le programme doit quitter la portée dans laquelle l’objet t a été créé et avant de quitter cette portée, le C++ détruit les objets automatiques locaux déjà construits. Le destructeur de t est donc appelé et la mémoire qu’il possède est libérée. Ce mécanisme fait partie de ce que l’on appelle le stack unwinding, que l’on peut traduire par déroulement de la pile donc, lorsqu’une exception remonte à travers les appels de fonctions, les objets automatiques rencontrés sont détruits conformément aux règles du langage. RAII permet ainsi de garantir que les ressources qu’ils possèdent sont libérées pendant ce processus. Nous voyons qu’avec une gestion manuelle, la situation est beaucoup plus délicate, soit : double∗ p = new double[1000]; operationQuiPeutEchouer(); car si operationQuiPeutEchouer() déclenche une exception, l’instruction n’est jamais exécutée. Nous obtenons à nouveau une fuite de mémoire. Avec un objet RAII : operationQuiPeutEchouer();
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources la destruction de t entraîne automatiquement la libération de sa ressource lors de la sortie de la portée. • RAII permet de rendre la libération des ressources indépendante du chemin suivi par le programme. • Sortie normale d’une portée, return anticipé ou propagation d’une exception : lorsque l’objet propriétaire est détruit, sa ressource est libérée. Notre première classe RAII est-elle vraiment sûre ? Notre classe Tableau semble maintenant résoudre le problème de la libération de la mémoire. Tableau(std::size_t n) donnees = new double[n]; Le constructeur acquiert la ressource et le destructeur la libère cependant, un problème important subsiste, que se passe-t-il si nous copions un objet Tableau ? Nous n’avons défini aucun constructeur de copie particulier donc le compilateur utilise le mécanisme de copie disponible par défaut et copie les membres de l’objet. Le membre important de notre classe est : Or copier un pointeur ne copie pas la zone de mémoire vers laquelle il pointe, c’est uniquement l’adresse contenue dans le pointeur qui est copiée. Après nous pouvons donc nous retrouver conceptuellement dans la situation suivante :
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources Les deux objets contiennent alors une adresse désignant la même zone de mémoire. Deux propriétaires pour une seule ressource Lorsque l’un des objets est détruit, son destructeur exécute l’instruction et la zone de mémoire est libérée. Mais l’autre objet contient toujours la même adresse et lorsqu’il est à son tour détruit, son destructeur tente d’exécuter une deuxième fois l’instruction sur une zone de mémoire qui a déjà été libérée. Il s’agit d’une double libération de la même ressource et le comportement du programme est alors indéfini. • Un pointeur ne représente pas, à lui seul, une notion de propriété. • Copier un pointeur copie une adresse, mais ne copie pas nécessairement la ressource vers laquelle cette adresse pointe. • Une classe qui possède directement une ressource doit donc définir très précisément ce que signifient la copie, l’affectation et la destruction de ses objets. La notion de propriété Le problème précédent met en évidence une notion fondamentale de la gestion moderne des ressources : la Lorsqu’un objet est responsable de la libération d’une ressource, il est naturel de dire qu’il en est le propriétaire. Dans notre première classe Tableau, nous voulions établir la relation : Tableau −→ propriétaire du tableau dynamique Mais la copie de l’objet a créé une situation ambiguë : deux objets −→ une seule ressource Qui doit alors libérer cette ressource ? Si les deux objets le font, nous obtenons une double libération ! Si aucun ne le fait, nous obtenons une fuite Il faut donc que la relation de propriété soit définie sans ambiguïté.
Accueil
2026 – C++ Partie III RAII et gestion moderne des ressources Vers la gestion moderne des ressources Il est parfaitement possible d’écrire nous-mêmes une classe qui gère correctement ces situations, il faudrait notamment définir précisément le comportement de l’objet lorsqu’il est copié, affecté ou déplacé. Mais cette gestion devient rapidement délicate et répétitive. Le C++ moderne fournit des classes spécialement conçues pour exprimer et gérer la propriété d’une ressource dont la plus fondamentale est : Son nom donne déjà une indication importante : unique −→ propriétaire unique Un std::unique_ptr représente une ressource possédant un seul propriétaire. Il applique le principe RAII : lorsque le unique_ptr est détruit, la ressource qu’il possède est automatiquement libérée, mais contrairement à notre classe Tableau rudimentaire, std::unique_ptr est conçu pour empêcher précisément le type de copie dangereuse que nous venons d’observer. Ce sera l’objet du chapitre suivant. Le principe RAII consiste à associer la durée de vie d’une ressource à celle d’un objet, qui en devient responsable. L’acquisition de la ressource intervient lors de l’initialisation de l’objet et sa libération lors de sa destruction. Cette approche permet notamment de garantir la libération des ressources, • lors d’une sortie normale d’une portée, • lors d’un return anticipé, • ou lors de la propagation d’une exception. RAII ne concerne pas uniquement la mémoire dynamique. Le même principe s’applique aux fichiers, aux connexions réseau, aux verrous et, plus généralement, à toute ressource qui doit être acquise puis libérée. Cependant, posséder directement une ressource impose également de définir ce qui doit se produire lorsqu’un objet est copié ou déplacé. La gestion moderne des ressources en C++ repose donc non seulement sur RAII, mais également sur une notion explicite de propriété. Dans le chapitre suivant, nous étudierons std::unique_ptr, qui permet de représenter une ressource possédant un propriétaire unique.
Accueil
P4-Chap-01 RAII et gestion moderne des ressources
Accueil
Termes à ajouter au glossaire

Chapitre 2 - std::unique_ptr et la propriété exclusive

Chapitre 2 – std::unique_ptr et la propriété exclusive

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
2 std::unique_ptr et la propriété exclusive 2.1 Du principe RAII à std::unique_ptr . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5 Un unique_ptr peut posséder un objet . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6 Que se passe-t-il lors d’une sortie anticipée ? . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.7.1 Le compilateur refuse la copie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.8 Mais peut-on changer de propriétaire ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III std::unique_ptr et la propriété exclusive
Accueil
std::unique_ptr et la propriété exclusive Du principe RAII à std::unique_ptr Dans le chapitre précédent, nous avons vu qu’une ressource peut être confiée à un objet chargé de la libérer lorsque sa durée de vie se termine. C’est le principe RAII. Nous avons cependant rencontré un problème avec notre classe Tableau : La copie de l’objet pouvait conduire à la copie du pointeur interne et deux objets pouvaient alors contenir l’adresse de la même zone de mémoire et tenter tous les deux de la libérer. Le problème fondamental n’était donc plus seulement : Qui libère la ressource ? Qui possède la ressource ? Le C++ moderne fournit plusieurs outils permettant d’exprimer explicitement cette notion de propriété. Le plus simple et le plus important est : Le terme unique est essentiel : Un std::unique_ptr représente la propriété exclusive d’une ressource. Autrement dit, à un instant donné, une ressource gérée par un unique_ptr possède un unique propriétaire. Notre premier unique_ptr Pour utiliser std::unique_ptr, nous devons inclure dans notre programme. 2026 – C++ Partie III std::unique_ptr et la propriété exclusive std::unique_ptr<int> p(new int(42)); std::cout << *p << ’\n’; std::unique_ptr<int> p(new int(42)); crée un objet p capable de posséder un objet de type int. alloue dynamiquement un entier et l’initialise avec la valeur 42 et l’adresse obtenue est confiée au unique_ptr. Nous pouvons représenter la situation ainsi : Le pointeur intelligent p est maintenant le propriétaire de cet entier. Accéder à la valeur Comme avec un pointeur classique, l’opérateur * permet d’accéder à l’objet possédé : std::cout << *p << ’\n’; Nous pouvons également modifier cette valeur : std::cout << *p << ’\n’; Où est passé delete ? C’est ici que le principe RAII intervient, considérons :
Accueil
2026 – C++ Partie III std::unique_ptr et la propriété exclusive std::unique_ptr<int> p(new int(42)); std::cout << *p << ’\n’; Lorsque l’exécution atteint la fin de la fonction, l’objet p est détruit càd que le destructeur de unique_ptr libère automatiquement l’objet dynamique qu’il possède et il n’est pas nécessaire d’écrire : Cette instruction serait d’ailleurs incorrecte car p n’est pas un pointeur brut de type int*, mais un objet std::unique_ptr<int> La destruction peut être représentée ainsi : destruction de la ressource possédée libération automatique de la mémoire • Un std::unique_ptr possède la ressource qui lui a été confiée. • Lorsque le unique_ptr est détruit, sa ressource est automatiquement détruite et la mémoire correspondante est libérée. • Il ne faut donc pas appeler manuellement delete sur la ressource possédée. L’écriture précédente fonctionne, càd que std::unique_ptr<int> p(new int(42)); est valable, mais le C++ moderne fournit une manière plus sûre de construire un unique_ptr : auto p = std::make_unique<int>(42); La fonction std::make_unique crée l’objet dynamique et retourne directement un unique_ptr qui en devient propriétaire. Pour l’utiliser, il faut également mettre Nous pouvons donc écrire auto p = std::make_unique<int>(42); std::cout << *p << ’\n’;
Accueil
2026 – C++ Partie III std::unique_ptr et la propriété exclusive std::make_unique<int>(42) construit un entier contenant 42 et place immédiatement sa gestion sous la responsabilité d’un unique_ptr. L’utilisation de auto n’est pas obligatoire car nous pouvons écrire explicitement std::unique_ptr<int> p = std::make_unique<int>(42); Le type de p est donc : std::unique_ptr<int> L’écriture avec auto : auto p = std::make_unique<int>(42); évite simplement de répéter une information que le compilateur peut déduire. • En C++ moderne, on préférera généralement : auto p = std::make_unique<int>(42); std::unique_ptr<int> p(new int(42)); • La ressource est ainsi créée directement sous le contrôle du unique_ptr. Un unique_ptr peut posséder un objet L’intérêt de unique_ptr ne se limite évidemment pas aux types fondamentaux. En effet, considérons une classe simple comme : Point(double x0, double y0)
Accueil
2026 – C++ Partie III std::unique_ptr et la propriété exclusive Nous pouvons créer dynamiquement un objet Point : auto p = std::make_unique<Point>(3.0, 4.0); L’objet Point est créé dynamiquement et p en devient l’unique propriétaire. Comme avec un pointeur classique, nous pouvons utiliser l’opérateur -> pour accéder aux membres de std::cout << p->getX() << ’\n’; std::cout << p->getY() << ’\n’; Nous pouvons donc utiliser un unique_ptr d’une manière très proche d’un pointeur classique : accès à l’objet possédé, accès à un membre de l’objet possédé. La différence fondamentale ne concerne donc pas principalement l’accès à l’objet, mais elle concerne sa propriété et sa durée de vie. Que se passe-t-il lors d’une sortie anticipée ? Reprenons maintenant le problème rencontré avec l’allocation manuelle, c.-à-d. la fuite de données. auto p = std::make_unique<int>(42); // Autres calculs… Si erreur est vraie, la fonction se termine avec return, mais avant de quitter la portée, l’objet local p est détruit autrement dit, son destructeur libère automatiquement la ressource qu’il possède. Nous retrouvons donc exactement le principe RAII étudié dans le chapitre précédent. fin de la durée de vie de p libération de sa ressource Le même principe s’applique lorsqu’une exception provoque la sortie de la portée.
Accueil
2026 – C++ Partie III std::unique_ptr et la propriété exclusive Peut-on copier un unique_ptr ? Nous arrivons maintenant au problème qui avait mis en difficulté notre première classe RAII. Créons un premier pointeur intelligent : auto p1 = std::make_unique<int>(42); Que devrait faire le programme ? Si la copie était autorisée, nous pourrions obtenir : Deux objets prétendraient alors être propriétaires de la même ressource et cela contredirait directement le principe de propriété exclusive de unique_ptr. Le compilateur refuse la copie Essayons le programme suivant : auto p1 = std::make_unique<int>(42); Le programme ne compile pas, ce n’est pas une limitation accidentelle de unique_ptr mais c’est au contraire une propriété fondamentale de sa conception. Un unique_ptr ne peut pas être copié. Le compilateur nous empêche ainsi de créer accidentellement deux propriétaires de la même ressource. • La propriété représentée par std::unique_ptr est exclusive. • Un unique_ptr ne peut donc pas être copié. • Cette interdiction est vérifiée par le compilateur : une erreur de conception susceptible de provoquer une mauvaise gestion de la ressource est ainsi détectée avant même l’exécution du Mais peut-on changer de propriétaire ? L’interdiction de copier pose immédiatement une nouvelle question, celle du changement de propriétaire.
Accueil
2026 – C++ Partie III std::unique_ptr et la propriété exclusive auto p1 = std::make_unique<int>(42); soit propriétaire de la ressource. Nous voulons maintenant que cette ressource appartienne à un autre unique_ptr, nommé p2. Nous ne voulons pas obtenir deux propriétaires mais effectuer : avec p1 qui ne possède plus rien. Il ne s’agit donc pas de copier la propriété mais de la transférer et cette distinction entre copie et transfert constitue l’une des évolutions majeures introduites par le C++ moderne. Elle nous conduira à étudier la sémantique de déplacement et notamment Avant d’aller plus loin avec unique_ptr, nous devons donc comprendre ce que signifie réellement déplacer
Accueil
P4-Chap-02 std::unique_ptr et la propriété exclusive
Accueil
Termes à ajouter au glossaire

Chapitre 3 - La sémantique de déplacement

Chapitre 3 – La sémantique de déplacement

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
3 La sémantique de déplacement 3.3 Pourquoi déplacer plutôt que copier ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4 Un premier déplacement avec std::move . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5 Que fait réellement std::move ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6 Les catégories d’expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6.1 Une expression peut désigner un objet . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6.3 Une expression peut produire une valeur temporaire . . . . . . . . . . . . . . . . . . 3.6.6 Type et catégorie sont deux propriétés différentes . . . . . . . . . . . . . . . . . . . . 3.7 Références lvalue et références rvalue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.7.1 Les références que nous connaissons déjà . . . . . . . . . . . . . . . . . . . . . . . . 3.7.2 Une référence lvalue ne se lie pas à une rvalue . . . . . . . . . . . . . . . . . . . . . 3.7.3 Le cas important des références constantes . . . . . . . . . . . . . . . . . . . . . . . 3.7.5 Une référence rvalue et une lvalue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.7.6 Pourquoi avoir créé un nouveau type de référence ? . . . . . . . . . . . . . . . . . . . 3.7.7 Une expérience avec deux fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.7.8 Comment traiter une lvalue comme source de déplacement ? . . . . . . . . . . . . . 3.7.9 Ce que fait réellement std::move . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.7.10 std::move ne déplace rien par lui-même . . . . . . . . . . . . . . . . . . . . . . . . . 3.7.11 Un piège important : une référence rvalue nommée . . . . . . . . . . . . . . . . . . . 3.7.12 Conséquence pour le constructeur de déplacement . . . . . . . . . . . . . . . . . . . 3.7.13 Résumé des distinctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.8 Le constructeur de déplacement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.8.1 Transférer la ressource . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.9 Utiliser notre constructeur de déplacement . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.10 Un objet déplacé existe toujours . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.11 L’affectation par déplacement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.13 Copie ou déplacement : une différence fondamentale . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III La sémantique de déplacement
Accueil
La sémantique de déplacement Copier ou déplacer ? Dans le chapitre précédent, nous avons rencontré une propriété fondamentale de std::unique_ptr qui Un unique_ptr ne peut pas être copié. auto p1 = std::make_unique<int>(42); La deuxième instruction est refusée par le compilateur. La raison est simple : p1 représente la propriété exclusive de l’entier dynamique et copier p1 conduirait à créer deux propriétaires de la même ressource. Mais une autre opération peut parfaitement être envisagée, nous pouvons vouloir que p1 cesse d’être propriétaire de la ressource et que p2 en devienne le nouveau propriétaire. Nous voulons donc passer de : Il ne s’agit plus de copier la ressource mais de la déplacer. Copie et déplacement La différence entre copie et déplacement est fondamentale en C++ moderne. Lors d’une copie, l’objet d’origine conserve son contenu et un nouvel objet reçoit une copie de celui-ci. 2026 – C++ Partie III La sémantique de déplacement objet A −→ ressource A objet A −→ ressource A objet B −→ ressource B Les deux objets continuent donc d’exister avec leur propre contenu. Lors d’un déplacement, on cherche au contraire à transférer les ressources appartenant à un objet vers un objet A −→ ressource le déplacement produit : objet A −→ état valide mais non spécifié objet B −→ ressource La ressource elle-même n’a pas nécessairement besoin d’être copiée, la ressource reste la même mais elle Pourquoi déplacer plutôt que copier ? Imaginons un objet contenant un tableau de plusieurs millions de valeurs. Une copie profonde de celui-ci pourrait nécessiter • l’allocation d’une nouvelle zone de mémoire ; • la copie de plusieurs millions de valeurs ; • la gestion ultérieure de deux allocations distinctes. Si nous supposons que l’objet d’origine n’est plus nécessaire, il serait alors inutile de recopier toutes les données et nous pourrions simplement transférer la propriété de la zone de mémoire au nouvel objet. Avant le déplacement : objet A −→ grande zone de mémoire Après le déplacement : objet A −→ aucune ressource objet B −→ la même zone de mémoire La zone de mémoire elle-même n’a pas été copiée, seules les informations permettant d’en transférer la propriété ont été modifiées. Pour certains objets, cette opération peut donc être beaucoup moins coûteuse Un premier déplacement avec std::move Revenons à unique_ptr. auto p1 = std::make_unique<int>(42);
Accueil
2026 – C++ Partie III La sémantique de déplacement La copie suivante est interdite : Mais nous pouvons écrire : auto p2 = std::move(p1); La fonction std::move est déclarée dans Un programme complet peut donc être : auto p1 = std::make_unique<int>(42); auto p2 = std::move(p1); std::cout << *p2 << ’\n’; auto p2 = std::move(p1); p2 est devenu propriétaire de l’entier. Dans le cas particulier de std::unique_ptr, p1 ne possède alors plus rien et devient un pointeur intelligent Vérifier l’état de p1 Nous pouvons le constater : std::cout << « p1 ne possede plus rien\n »; std::cout << *p2 << ’\n’; permet toujours d’accéder à la valeur. avant le déplacement après le déplacement
Accueil
2026 – C++ Partie III La sémantique de déplacement • Le déplacement d’un std::unique_ptr transfère la propriété de la ressource. • Après le déplacement, le unique_ptr destination possède la ressource et le unique_ptr source Que fait réellement std::move ? Le nom std::move peut être trompeur car il pourrait laisser penser que cette fonction déplace elle-même les données. Ce n’est pas exactement ce qui se produit, std::move ne déplace pas directement une ressource. Il indique essentiellement au compilateur que l’objet qui lui est fourni peut être traité comme une valeur dont les ressources peuvent être transférées et c’est ensuite le constructeur de déplacement ou l’opérateur d’affectation par déplacement du type concerné qui effectue réellement le transfert. auto p2 = std::move(p1); peut être lu conceptuellement comme : p1 peut maintenant être utilisé comme source d’un déplacement. Le mécanisme de déplacement appartient donc au type de l’objet, et non à std::move lui-même. • Malgré son nom, std::move ne déplace pas lui-même les ressources. • Il permet à une opération de déplacement, si elle existe pour le type concerné, d’être sélectionnée. Les catégories d’expressions Pour comprendre correctement la sémantique de déplacement, il faut examiner plus précisément une notion que nous avons utilisée depuis le début sans réellement avoir besoin de la formaliser, les expressions. Considérons l’instruction suivante : Nous pouvons distinguer ici plusieurs notions. • a est le nom d’un objet ; • cet objet est de type int ; • cet objet contient la valeur 10. Mais lorsque nous utilisons ensuite :
Accueil
2026 – C++ Partie III La sémantique de déplacement dans une instruction, a constitue également une expression, par exemple : Dans cette instruction, l’expression a permet d’accéder à l’objet nommé a et d’obtenir sa valeur. Cette distinction peut sembler inutile dans des programmes simples, mais elle devient fondamentale avec le C++ moderne, car une expression possède notamment : • une catégorie de valeur. La catégorie d’une expression indique notamment si cette expression désigne un objet possédant une identité, ou si elle représente plutôt une valeur temporaire et c’est cette distinction qui permettra au compilateur de déterminer si un objet doit être copié ou peut être déplacé. Une expression peut désigner un objet L’objet a existe à une certaine adresse en mémoire que nous pouvons vérifier : std::cout << &a << ’\n’; Tant que cet objet existe, nous pouvons le retrouver grâce à son nom et à son adresse, l’expression : désigne donc un objet possédant une identité. Une expression de cette nature appartient à la catégorie appelée : Le terme lvalue est historique. Il provient de l’expression anglaise left value, car une lvalue pouvait typiquement apparaître à gauche d’une affectation : Dans cette instruction : Cette origine historique est cependant trompeuse et ne constitue plus une définition correcte. L’expression a est toujours une lvalue, pourtant est interdit puisque l’objet est constant. Il ne faut donc pas retenir :
Accueil
2026 – C++ Partie III La sémantique de déplacement Une lvalue est quelque chose qui peut être placé à gauche du signe =. Une interprétation beaucoup plus utile est : Une lvalue est une expression qui désigne un objet possédant une identité. Les expressions a et b sont toutes les deux des lvalues car elles désignent deux objets distincts, chacun possédant sa propre adresse. Une expression peut produire une valeur temporaire Considérons maintenant : effectue un calcul et produit la valeur 15 qui sert à initialiser b. Mais le résultat intermédiaire de l’expression a + 5 n’est pas un objet nommé que nous avons l’intention de retrouver ultérieuremen, il s’agit d’une valeur temporaire. produit temporairement une valeur utilisée pour initialiser b. Ce type d’expression appartient à la grande famille des rvalues. Le terme rvalue provient historiquement de right value. Une rvalue apparaissait typiquement à droite d’une Dans ces exemples, les expressions :
Accueil
2026 – C++ Partie III La sémantique de déplacement Là encore, l’origine historique du terme ne doit pas être prise comme une définition complète. Pour comprendre la sémantique de déplacement, nous pouvons retenir dans un premier temps : Une rvalue représente typiquement une valeur temporaire ou une valeur dont les ressources peuvent être récupérées. Cette deuxième possibilité deviendra particulièrement importante lorsque nous étudierons std::move. Dans la dernière instruction, est une lvalue et de même Ces expressions désignent des objets possédant une identité. En revanche : produit une valeur temporaire qui est une rvalue. Enfin, après l’initialisation : est à son tour une lvalue. Il faut donc bien distinguer l’expression utilisée pour calculer une valeur de l’objet qui reçoit ensuite cette Type et catégorie sont deux propriétés différentes Une expression possède un type et une catégorie de valeur et ces deux notions ne doivent pas être confondues.
Accueil
2026 – C++ Partie III La sémantique de déplacement est de type int et appartient à la catégorie des lvalues. le résultat est également de type int, mais l’expression appartient à une catégorie différente : c’est ici une Le type ne suffit donc pas à déterminer la catégorie d’une expression. • Les catégories lvalue et rvalue caractérisent des expressions. • Une lvalue désigne typiquement un objet possédant une identité. • Une rvalue représente typiquement une valeur temporaire ou une valeur dont les ressources peuvent être récupérées. • Une expression possède à la fois un type et une catégorie de valeur. Deux expressions de même type peuvent donc appartenir à des catégories différentes. Références lvalue et références rvalue Nous savons maintenant qu’une expression possède non seulement un type, mais également une catégorie, dans les cas simples, étudiés jusqu’ici, nous avons distingué : • les lvalues, qui désignent des objets possédant une identité ; • les rvalues, qui représentent notamment des valeurs temporaires ou des valeurs dont les ressources peuvent être récupérées. Cette distinction permet de comprendre une nouveauté fondamentale introduite avec C++11 : les références rvalue. Les références que nous connaissons déjà Nous avons déjà largement utilisé les références classiques :
Accueil
2026 – C++ Partie III La sémantique de déplacement crée une référence vers l’objet a. Cette référence est plus précisément appelée une référence lvalue. Le type de r est donc : La référence (alias) constitue un autre moyen d’accéder au même objet. Si on écrit le code std::cout << a << ’\n’; celui-ci affichera : Une référence lvalue ne se lie pas à une rvalue Essayons maintenant : Cette instruction est refusée par le compilateur. Pourquoi ? Or une référence lvalue modifiable de type : ne peut pas se lier directement à une rvalue. Nous pouvons donc écrire : Le cas important des références constantes Il existe cependant une règle que nous connaissons déjà : Cette instruction est parfaitement valide. Une référence lvalue constante :
Accueil
2026 – C++ Partie III La sémantique de déplacement peut se lier à une lvalue mais également à une rvalue Dans ce dernier cas, le C++ matérialise un objet temporaire permettant à la référence de s’y lier. Lorsque la référence locale est directement liée de cette manière, la durée de vie de ce temporaire est prolongée jusqu’à la fin de la durée de vie de la référence. Cette propriété explique notamment pourquoi les références constantes sont très utiles pour passer des objets à une fonction sans effectuer de copie : void afficher(const Objet& objet) L’objet n’est pas copié et la fonction ne peut pas le modifier par l’intermédiaire de cette référence. Les références rvalue C++11 introduit un deuxième type de référence : désigne une référence rvalue. Les deux caractères && ne signifient donc pas “référence vers une référence” ni “double référence”, ils désignent une catégorie particulière de référence. Une référence rvalue peut se lier à une rvalue, par exemple : est valide puisque l’expression :
Accueil
2026 – C++ Partie III La sémantique de déplacement Une référence rvalue et une lvalue Essayons maintenant : Cette instruction est refusée, car a est une lvalue et elle ne peut donc pas se lier directement à une référence Nous pouvons résumer les règles simples de liaison que nous venons d’étudier : Pourquoi avoir créé un nouveau type de référence ? À première vue, l’existence de T&& peut sembler inutile, après tout, const T& permet déjà de se lier à une rvalue. Mais il existe une différence fondamentale. Une fonction recevant : promet essentiellement de consulter l’objet sans le modifier et donc ne peut pas raisonnablement lui retirer ses ressources. En revanche, une fonction recevant : peut être conçue précisément pour recevoir un objet dont les ressources peuvent être transférées. C’est cette distinction qui rend possible la sémantique de déplacement. Une expérience avec deux fonctions Nous pouvons rendre cette distinction visible avec deux fonctions : void identifier(int& x) std::cout << « lvalue\n »; void identifier(int&& x) std::cout << « rvalue\n »;
Accueil
2026 – C++ Partie III La sémantique de déplacement Quand la fonction reçoit l’expression a étant une lvalue c’est void identifier(int& x) qui est sélectionnée. En revanche si la fonction recoit c’est l’autre fonction qui est choisie, celle avec la signature correspondant à une rvalue, c.-à-d. la fonction : void identifier(int&& x). Le compilateur est ainsi capable de distinguer les deux catégories d’expressions et de sélectionner des fonctions différentes. Comment traiter une lvalue comme source de déplacement ? Revenons maintenant à un objet existant : Elle ne peut donc pas se lier directement à un paramètre de type : Pourtant, le programmeur peut savoir que l’objet a n’a plus besoin de conserver ses ressources. Il faut alors pouvoir exprimer explicitement l’intention suivante : Cet objet existe toujours, mais j’autorise maintenant l’utilisation de ses ressources pour effectuer un C’est précisément le rôle de
Accueil
2026 – C++ Partie III La sémantique de déplacement Ce que fait réellement std::move Dans cette expression : est une lvalue, en revanche produit une expression appartenant à la famille des rvalues. Plus précisément, cette expression appartient à la catégorie appelée xvalue 1 . Nous pouvons donc écrire : int&& r = std::move(a); Pour utiliser std::move, il faut inclure Pour l’instant on retient std::move(a) −→ rvalue pouvant être utilisée comme source d’un déplacement std::move ne déplace rien par lui-même Le nom std::move est quelque peu trompeur. Considérons simplement : Cette instruction ne transfère aucune ressource car std::move permet essentiellement de changer la manière dont l’expression est présentée au mécanisme de sélection des fonctions. Le déplacement proprement dit doit être réalisé par une opération capable de déplacer l’objet. Tableau b(std::move(a)); permet au constructeur Tableau(Tableau&& autre) 1. Nous préciserons cette catégorie ultérieurement.
Accueil
2026 – C++ Partie III La sémantique de déplacement d’être sélectionné. C’est ensuite ce constructeur qui effectue réellement le transfert des ressources. • std::move ne déplace pas lui-même un objet. • Il produit une expression permettant au compilateur de sélectionner une opération de déplacement lorsque celle-ci existe. • Le déplacement réel est effectué par le constructeur de déplacement, l’opérateur d’affectation par déplacement ou une autre opération appropriée. Un piège important : une référence rvalue nommée Considérons maintenant Nous savons que le type déclaré de r est Il s’agit donc d’une référence rvalue. Mais considérons maintenant l’expression Cette expression est une lvalue même si cela peut sembler contradictoire. Il faut cependant se souvenir de la distinction établie dans la section précédente Le type d’une variable et la catégorie d’une expression sont deux notions différentes. La variable r possède maintenant un nom et l’expression constituée par ce nom permet de retrouver l’objet auquel elle fait référence, c’est donc une lvalue. catégorie de l’expression r Conséquence pour le constructeur de déplacement Cette règle devient particulièrement importante dans un constructeur de déplacement. Considérons : Tableau(Tableau&& autre) Le paramètre autre est déclaré avec le type Mais à l’intérieur du constructeur, l’expression
Accueil
2026 – C++ Partie III La sémantique de déplacement est une lvalue. Pourquoi ? Parce que autre possède maintenant un nom et désigne un objet identifiable. Cette distinction peut devenir importante si certains membres de autre doivent eux-mêmes être déplacés. Nous rencontrerons alors des expressions telles que : std::move(autre.membre) qui permettront à nouveau d’autoriser le déplacement de ce membre. Résumé des distinctions Il faut maintenant distinguer clairement trois notions. l’entier contenant 10 Catégorie de l’expression r Il n’y a aucune contradiction, la syntaxe && caractérise ici le type de la référence et les termes lvalue et rvalue caractérisent la catégorie d’une expression. • Une référence rvalue s’écrit : • Les deux caractères && ne représentent pas une double référence. • Ils permettent notamment de recevoir des rvalues et constituent le mécanisme fondamental sur lequel repose la sémantique de déplacement. • Il faut cependant toujours distinguer le type d’une référence de la catégorie de l’expression qui r est de type int&&, mais l’expression r est une lvalue. Le constructeur de déplacement Considérons une classe qui possède directement une zone de mémoire dynamique
Accueil
2026 – C++ Partie III La sémantique de déplacement Tableau(std::size_t n) : donnees(new double[n]), taille(n) Nous voulons maintenant pouvoir transférer la ressource appartenant à un objet vers un autre objet. Un constructeur de déplacement possède une forme semblable à : Tableau(Tableau&& autre) est une référence rvalue vers un objet de type Tableau. Transférer la ressource Nous pouvons écrire : Tableau(Tableau&& autre) : donnees(autre.donnees), taille(autre.taille) autre.donnees = nullptr; Examinons cette opération. donnees = autre.donnees; ne copie pas le tableau, elle copie uniquement l’adresse de la zone de mémoire tel que le nouvel objet possède maintenant cette adresse. Mais il faut immédiatement empêcher l’ancien objet de continuer à se comporter comme propriétaire de cette même ressource en écrivant autre.donnees = nullptr; Ainsi, lorsque l’ancien objet sera détruit, son destructeur exécutera : ce qui est autorisé et ne provoque aucune libération supplémentaire, par conséquent la propriété de la ressource a réellement été transférée. Aucune copie du tableau Le constructeur de déplacement n’a pas créé un nouveau tableau de n éléments, il a simplement transféré l’adresse du tableau existant.
Accueil
2026 – C++ Partie III La sémantique de déplacement Nous sommes passés de : autre.donnees −→ tableau dynamique autre.donnees −→ nullptr donnees −→ tableau dynamique La ressource n’a pas été dupliquée, sa propriété a changé. Utiliser notre constructeur de déplacement Nous pouvons maintenant écrire : Tableau b(std::move(a)); permet au constructeur de déplacement d’être utilisé et après cette opération, b possède le tableau dynamique initialement possédé par a. L’objet a existe toujours, mais il ne possède plus cette ressource. Un objet déplacé existe toujours Il est très important de ne pas confondre déplacer les ressources d’un objet détruire cet objet. Tableau b(std::move(a)); l’objet a existe toujours et il sera détruit normalement lorsqu’il arrivera à la fin de sa durée de vie. Dans notre propre classe Tableau, nous avons choisi de placer explicitement l’objet source dans l’état Nous connaissons donc précisément cet état. Pour les types de la bibliothèque standard, la règle générale est qu’après un déplacement l’objet source reste dans un état valide mais dont la valeur n’est généralement pas spécifiée et donc, il peut donc être détruit ou recevoir une nouvelle valeur. Il ne faut en revanche pas supposer qu’il conserve nécessairement son ancienne valeur.
Accueil
2026 – C++ Partie III La sémantique de déplacement • Un objet déplacé n’est pas détruit. • Il continue d’exister et son destructeur sera appelé normalement. • Pour les types de la bibliothèque standard, après un déplacement, l’objet source reste généralement dans un état valide mais dont la valeur n’est pas spécifiée. • Dans le cas particulier de std::unique_ptr, l’état est précisément défini : le pointeur source L’affectation par déplacement Le constructeur de déplacement intervient lors de la construction d’un nouvel objet Tableau b(std::move(a)); Mais les deux objets peuvent déjà exister Il ne s’agit plus d’une construction, mais d’une affectation. Le C++ permet donc également de définir un opérateur d’affectation par déplacement Tableau& operator=(Tableau&& autre) donnees = autre.donnees; taille = autre.taille; autre.donnees = nullptr; Avant de recevoir la nouvelle ressource, l’objet destination doit libérer celle qu’il possède déjà Il peut ensuite prendre possession de la ressource de autre et enfin, l’objet source cesse d’en être propriétaire autre.donnees = nullptr; Retour à std::unique_ptr Nous pouvons maintenant comprendre précisément l’instruction qui avait motivé ce chapitre. auto p1 = std::make_unique<int>(42); auto p2 = std::move(p1);
Accueil
2026 – C++ Partie III La sémantique de déplacement std::move(p1) permet d’utiliser la sémantique de déplacement de std::unique_ptr. La propriété de la ressource est transférée de p1 vers p2 et après le déplacement est vrai, tandis que permet toujours d’accéder à la valeur 42. Nous comprenons maintenant pourquoi la copie : est interdite alors que auto p2 = std::move(p1); Dans le premier cas, nous demanderions deux propriétaires et dans le second, nous transférons la propriété d’un objet vers un autre. Copie ou déplacement : une différence fondamentale Nous pouvons maintenant résumer les deux opérations. conserve ses ressources cède ses ressources reçoit les ressources existantes généralement dupliquée généralement transférée potentiellement important souvent beaucoup plus faible Cette différence devient particulièrement importante pour les objets possédant de grandes quantités de données ou des ressources dont la copie n’a pas de sens. La sémantique de déplacement, introduite avec C++11, permet de transférer les ressources d’un objet vers un autre sans devoir nécessairement les copier, elle repose notamment sur : • les références rvalue, écrites Type&& ; • les constructeurs de déplacement ; • les opérateurs d’affectation par déplacement ; std::move ne déplace pas lui-même une ressource mais permet à l’objet concerné d’être utilisé comme source d’une opération de déplacement. Après un déplacement, l’objet source existe toujours et doit pouvoir être détruit correctement. La sémantique de déplacement est particulièrement importante pour std::unique_ptr, car celui-ci ne peut pas être copié mais sa propriété peut en revanche être transférée :
Accueil
2026 – C++ Partie III La sémantique de déplacement auto p1 = std::make_unique<int>(42); auto p2 = std::move(p1); Après cette opération, p2 possède la ressource et p1 est vide. La sémantique de déplacement ne concerne cependant pas uniquement les pointeurs intelligents. Elle constitue un mécanisme général du C++ moderne et intervient dans de nombreux types de la bibliothèque
Accueil
P4-Chap-03 La sémantique de déplacement
Accueil
Termes à ajouter au glossaire

Chapitre 4 - std::unique_ptr en pratique

Chapitre 4 – std::unique_ptr en pratique

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
4 std::unique_ptr en pratique 4.6 Passer un unique_ptr à une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.6.1 La fonction ne devient pas propriétaire . . . . . . . . . . . . . . . . . . . . . . . . . . 4.6.2 La fonction devient propriétaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.7 Retourner un unique_ptr depuis une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . 4.7.1 Retourner une variable locale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.10.1 get() et release() ne font pas la même chose . . . . . . . . . . . . . . . . . . . . 4.11 unique_ptr et tableaux dynamiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.11.1 Mais faut-il utiliser unique_ptr<T[]> ? . . . . . . . . . . . . . . . . . . . . . . . . . 4.12 Faut-il toujours utiliser un unique_ptr ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.13 unique_ptr et pointeur brut : deux rôles différents . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III std::unique_ptr en pratique
Accueil
std::unique_ptr en pratique Retour à std::unique_ptr Nous avons introduit std::unique_ptr comme un outil permettant d’appliquer le principe RAII à une ressource possédant un propriétaire unique, cependant nous avions rencontré une difficulté : auto p1 = std::make_unique<int>(42); Cette copie est interdite. Un unique_ptr représente une propriété exclusive, deux unique_ptr ne doivent donc pas devenir propriétaires de la même ressource. Cette interdiction nous a conduits à étudier la sémantique de déplacement que nous pouvons maintenant comprendre et utiliser correctement : auto p2 = std::move(p1); La propriété n’est pas copiée mais transférée. Créer un unique_ptr Pour utiliser std::unique_ptr, nous devons inclure : Nous avons déjà rencontré l’écriture std::unique_ptr<int> p(new int(42)); qui certes, est valide, mais en C++ moderne nous préférerons généralement : auto p = std::make_unique<int>(42); La fonction std::make_unique construit l’objet et retourne un unique_ptr qui en devient immédiatement propriétaire (l’utilisation de auto évite ici de répéter le type). Un objet d’une classe 2026 – C++ Partie III Point(double x0, double y0) std::unique_ptr en pratique Nous pouvons créer dynamiquement un Point auto p = std::make_unique<Point>(3.0, 4.0); Le type déduit de p est : std::unique_ptr<Point> et p devient l’unique propriétaire du Point. Utiliser l’objet possédé Un unique_ptr fournit les opérateurs habituels permettant d’accéder à l’objet qu’il possède. auto p = std::make_unique<int>(42); std::cout << *p << ’\n’; donne accès à l’objet possédé, pour un objet : auto p = std::make_unique<Point>(3.0, 4.0); std::cout << p->getX() << ’\n’; std::cout << p->getY() << ’\n’; L’opérateur -> fonctionne donc d’une manière similaire à celle d’un pointeur classique donc, la différence fondamentale entre un pointeur brut et un unique_ptr ne réside pas principalement dans la manière d’accéder à l’objet mais elle réside dans la propriété et la gestion de sa durée de vie. Transférer la propriété auto p1 = std::make_unique<int>(42);
Accueil
2026 – C++ Partie III std::unique_ptr en pratique Nous ne pouvons pas écrire : car cela demanderait une copie. auto p2 = std::move(p1); Nous savons maintenant exactement pourquoi, l’expression permet au constructeur de déplacement de unique_ptr d’être utilisé ainsi la propriété de la ressource est Dans le cas de unique_ptr, l’état de l’objet source après déplacement est précisément défini : il devient Tester un unique_ptr Un unique_ptr ne possède pas nécessairement une ressource. Nous pouvons par exemple déclarer std::unique_ptr<int> p; std::unique_ptr<int> p = nullptr; Dans les deux cas, p est vide. Nous pouvons le tester : std::cout << *p << ’\n’; La condition est vraie si p possède un objet. Nous pouvons également écrire :
Accueil
2026 – C++ Partie III std::unique_ptr en pratique std::cout << « Aucune ressource\n »; std::cout << « Aucune ressource\n »; • Avant de déréférencer un unique_ptr susceptible d’être vide, il faut vérifier qu’il possède effectivement un objet. constitue la forme la plus simple de ce test. Passer un unique_ptr à une fonction La notion de propriété devient particulièrement importante lorsqu’un unique_ptr est passé à une fonction. Il faut se poser une question avant même de choisir le type du paramètre, la question est : La fonction doit-elle devenir propriétaire de la ressource ? La réponse détermine la manière dont nous transmettrons l’objet. La fonction ne devient pas propriétaire Supposons qu’une fonction doive uniquement consulter un Point : void afficher(const Point& point) std::cout << point.getX() << ’ ’ << point.getY() << ’\n’; Nous pouvons avoir : auto p = std::make_unique<Point>(3.0, 4.0); La fonction reçoit une référence vers le Point, elle ne reçoit pas la propriété de la ressource, après l’appel, p en reste propriétaire. C’est généralement une excellente manière d’exprimer l’intention : la fonction utilise l’objet, mais ne le possède pas.
Accueil
2026 – C++ Partie III std::unique_ptr en pratique La fonction devient propriétaire Supposons maintenant que la fonction doive prendre possession de l’objet. Nous pouvons écrire : void traiter(std::unique_ptr<Point> p) std::cout << p->getX() << ’\n’; Mais l’appel suivant est impossible : auto p = std::make_unique<Point>(3.0, 4.0); Cela demanderait de copier le unique_ptr. Donc nous devons explicitement transférer sa propriété : traiter(std::move(p)); Après cet appel, le unique_ptr local p est vide car la propriété a été transférée au paramètre de la fonction. Lorsque ce paramètre sera détruit, la ressource sera elle aussi détruite, sauf si la fonction transfère à son tour cette propriété ailleurs. • Passer un unique_ptr par valeur exprime une intention forte : la fonction reçoit la propriété de la ressource. • L’appel nécessite donc généralement un transfert explicite avec std::move. Retourner un unique_ptr depuis une fonction Une fonction peut également créer une ressource et en transférer la propriété à son appelant. std::unique_ptr<Point> creerPoint(double x, double y) return std::make_unique<Point>(x, y); Nous pouvons ensuite écrire : auto p = creerPoint(3.0, 4.0); La fonction crée le Point et retourne un unique_ptr qui en possède la propriété. Cette propriété est alors transférée au unique_ptr p de l’appelant. Il n’est pas nécessaire d’écrire : std::make_unique<Point>(x, y) L’écriture simple : return std::make_unique<Point>(x, y); est correcte et préférable.
Accueil
2026 – C++ Partie III std::unique_ptr en pratique Retourner une variable locale Nous pouvons également rencontrer : std::unique_ptr<Point> creerPoint(double x, double y) auto p = std::make_unique<Point>(x, y); Cette écriture est également valide. Le langage permet ici le retour de l’objet sans exiger une copie interdite du unique_ptr. Selon le cas, le compilateur peut notamment éliminer la construction intermédiaire ou utiliser les règles de déplacement applicables au retour d’une variable locale. Il n’est généralement pas nécessaire d’écrire : return std::move(p); et cette écriture peut même empêcher certaines optimisations de copie. doit donc être préférée ici. Un unique_ptr peut fournir le pointeur brut vers l’objet qu’il possède. Pour cela, il dispose de : auto p = std::make_unique<int>(42); int* brut = p.get(); Mais il faut être extrêmement précis : brut ne devient pas propriétaire de la ressource. Le propriétaire reste p. Il ne faut donc surtout pas écrire : La ressource sera libérée automatiquement par p.
Accueil
2026 – C++ Partie III std::unique_ptr en pratique Pourquoi utiliser get() ? get() est principalement utile lorsqu’une fonction ou une bibliothèque demande un pointeur brut sans prendre la propriété de l’objet. void ancienneFonction(Point* p); Nous pouvons appeler : auto p = std::make_unique<Point>(3.0, 4.0); ancienneFonction(p.get()); Le unique_ptr reste propriétaire de l’objet et la fonction reçoit seulement son adresse. • get() donne accès au pointeur brut contenu dans un • unique_ptr, mais ne transfère aucune propriété. Le pointeur retourné par get() ne doit donc pas être libéré avec delete. Un unique_ptr peut abandonner la ressource qu’il possède en la détruisant. Nous pouvons écrire : auto p = std::make_unique<int>(42); l’objet dynamique possédé est détruit et unique_ptr devient ensuite vide Nous pouvons également lui confier une nouvelle ressource, bien que dans le code moderne on préfère généralement construire directement la nouvelle propriété de manière claire. L’intérêt essentiel de reset() est de permettre la destruction anticipée de la ressource sans détruire le unique_ptr lui-même. La fonction release() Il existe une autre opération dont le comportement est très différent : auto p = std::make_unique<int>(42); int* brut = p.release();
Accueil
2026 – C++ Partie III std::unique_ptr en pratique Après cette opération, p est vide, mais contrairement à reset(), l’objet dynamique n’a pas été détruit. Nous avons maintenant : Le unique_ptr a abandonné la propriété de la ressource et retourné son adresse et la gestion automatique de cette ressource a cessé. Dans cet exemple particulier, si aucun autre mécanisme ne reprend la propriété, il faudrait finalement écrire pour éviter une fuite de mémoire. get() et release() ne font pas la même chose La distinction est fondamentale, avec int* brut = p.get(); p reste propriétaire et avec int* brut = p.release(); p abandonne la propriété. Nous pouvons résumer : conservée par unique_ptr • release() doit être utilisé avec prudence. • Il retire la ressource du contrôle du unique_ptr sans la détruire. • À partir de cet instant, un autre mécanisme doit reprendre la responsabilité de cette ressource, faute de quoi une fuite est possible. unique_ptr et tableaux dynamiques Un unique_ptr peut également posséder un tableau dynamique. Nous pouvons écrire : auto p = std::make_unique<double[]>(1000); Le type déduit est : std::unique_ptr<double[]> Nous pouvons accéder aux éléments avec l’opérateur [] :
Accueil
2026 – C++ Partie III std::cout << p[0] << ’\n’; std::unique_ptr en pratique Lorsque p est détruit, le tableau est automatiquement libéré correctement et il n’est plus nécessaire d’écrire : La distinction entre destruction d’un objet unique et destruction d’un tableau est gérée par le type du Mais faut-il utiliser unique_ptr<T[]> ? La possibilité existe et peut être utile dans certaines situations. Cependant, pour représenter une collection dynamique d’objets, le C++ moderne fournit généralement un outil plus approprié qui est : Un vector connaît notamment sa taille et fournit de nombreuses opérations destinées à la gestion d’une auto p = std::make_unique<double[]>(1000); ne doit pas être considéré comme le remplacement systématique de : double* p = new double[1000]; Dans de nombreux cas, nous préférerons : std::vector<double> valeurs(1000); Nous étudierons ce conteneur dans un chapitre ultérieur. Faut-il toujours utiliser un unique_ptr ? L’étude des pointeurs intelligents pourrait donner l’impression qu’en C++ moderne il faut créer les objets avec make_unique mais ce serait une conclusion incorrecte. Si la durée de vie de cet objet correspond naturellement à la portée dans laquelle il est utilisé, cette écriture est simple et efficace. Il n’y a aucune raison de remplacer systématiquement cet objet par : auto p = std::make_unique<Point>(3.0, 4.0); Cette deuxième écriture introduit une allocation dynamique et une indirection supplémentaires. Un unique_ptr est utile lorsqu’une allocation dynamique et une notion de propriété sont réellement nécessaires.
Accueil
2026 – C++ Partie III std::unique_ptr en pratique • Le C++ moderne ne recommande pas de remplacer tous les objets par des pointeurs intelligents. • Lorsque l’allocation dynamique n’est pas nécessaire, un objet automatique ordinaire est généralement préférable : • Lorsqu’une ressource dynamique doit posséder un propriétaire unique, std::unique_ptr constitue alors l’outil naturel. unique_ptr et pointeur brut : deux rôles différents Nous pouvons maintenant distinguer deux notions qui étaient souvent confondues dans le C++ traditionnel. qui représente essentiellement une adresse. À lui seul, son type ne précise pas clairement si le pointeur est propriétaire de l’objet désigné. std::unique_ptr<Point> qui exprime au contraire explicitement une notion de propriété. Cet objet possède exclusivement la ressource désignée et devra assurer sa destruction. Cette information fait donc partie du type lui-même. C’est l’une des évolutions importantes de la gestion moderne des ressources : la notion de propriété devient beaucoup plus visible dans le code. std::unique_ptr représente la propriété exclusive d’une ressource et on le construit généralement avec : auto p = std::make_unique<Type>(arguments); Il ne peut pas être copié : mais sa propriété peut être transférée auto p2 = std::move(p1); Un unique_ptr peut être vide et peut être testé simplement avec : Lorsqu’une fonction doit seulement utiliser l’objet sans en devenir propriétaire, on peut généralement lui transmettre directement une référence vers l’objet
Accueil
2026 – C++ Partie III std::unique_ptr en pratique Mais lorsqu’une fonction doit devenir propriétaire, le unique_ptr peut lui être transmis par valeur avec un traiter(std::move(p)); Les principales opérations particulières sont : obtenir l’adresse sans transférer la propriété, détruire la ressource possédée, abandonner la propriété sans détruire la ressource. Enfin, unique_ptr ne doit pas être utilisé lorsqu’aucune allocation dynamique n’est nécessaire. Le principe général est donc : Utiliser un objet automatique lorsque cela suffit ; utiliser unique_ptr lorsqu’une ressource dynamique doit avoir un propriétaire unique.
Accueil
P4-Chap-04 std::unique_ptr en pratique
Accueil
Termes à ajouter au glossaire

Chapitre 5 - std::shared_ptr et std::weak_ptr

Chapitre 5 – std::shared_ptr et std::weak_ptr

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
5 std::shared_ptr et std::weak_ptr 5.1 Quand un propriétaire unique ne suffit plus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3 Le comptage de références . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5 Une expérience avec un objet . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.6 Copier ou déplacer un shared_ptr . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.7 Passer un shared_ptr à une fonction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.9 Le problème des références circulaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.10 Le problème vient de la propriété . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.11 std::weak_ptr : observer sans posséder . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.12 Pourquoi un weak_ptr ne peut-il pas être déréférencé directement ? . . . . . . . . . . . . . 5.13 Tester l’existence de la ressource . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.15 Rompre une référence circulaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.16 Les trois modèles de relation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.17 Quel pointeur intelligent choisir ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III std::shared_ptr et std::weak_ptr
Accueil
std::shared_ptr et std::weak_ptr Quand un propriétaire unique ne suffit plus Dans le chapitre précédent, nous avons étudié : qui exprime une propriété exclusive. À un instant donné, une ressource possède un seul propriétaire et cette situation est généralement souhaitable, car elle permet de répondre sans ambiguïté à la question : Qui est responsable de la destruction de la ressource ? Mais certaines architectures nécessitent réellement que plusieurs objets partagent la propriété d’une même ressource. Considérons par exemple un objet représentant des données utilisées simultanément par plusieurs composants d’un programme. Nous voulons alors obtenir une situation de la forme : La ressource doit continuer d’exister tant qu’au moins un de ces objets en a encore besoin et ne doit être détruite que lorsque le dernier propriétaire disparaît. C’est le rôle de : Le principe de shared_ptr Un shared_ptr est un pointeur intelligent permettant à plusieurs objets de partager la propriété d’une même ressource mais contrairement à unique_ptr, un shared_ptr peut être copié. auto p1 = std::make_shared<int>(42); Nous pouvons ensuite écrire : 2026 – C++ Partie III std::shared_ptr et std::weak_ptr Cette fois, la copie est autorisée et après cette opération, p1 et p2 désignent le même entier dynamique et en partagent la propriété : La question devient alors : Quand faut-il détruire l’entier ? La réponse repose sur un mécanisme appelé comptage de références. Le comptage de références Un shared_ptr participe à un mécanisme qui comptabilise le nombre de propriétaires partageant la même auto p1 = std::make_shared<int>(42); il existe un propriétaire par conséquent le compteur vaut donc conceptuellement : il existe deux propriétaires et le compteur vaut Si p2 est détruit, le compteur redescend à : La ressource ne doit pas encore être détruite puisque p1 la possède toujours. Lorsque p1 est finalement détruit, le nombre de propriétaires devient : La ressource est alors automatiquement détruite.
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr Observer le compteur La fonction membre : permet d’observer le nombre de propriétaires. auto p1 = std::make_shared<int>(42); std::cout << p1.use_count() << ’\n’; std::cout << p1.use_count() << ’\n’; std::cout << p2.use_count() << ’\n’; std::cout << p1.use_count() << ’\n’; Le programme affiche : Lorsque p2 arrive à la fin de sa portée, il est détruit et le compteur passe alors automatiquement de 2 à 1. Comme pour unique_ptr, il existe une fonction permettant de construire directement l’objet géré : Nous préférerons donc généralement : auto p = std::make_shared<Point>(3.0, 4.0); à une construction explicite à partir de new. std::shared_ptr<Point> et l’objet Point est automatiquement détruit lorsque le dernier propriétaire disparaît.
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr Une expérience avec un objet Pour rendre la destruction visible, utilisons une classe simple : std::cout << « Construction\n »; std::cout << « Destruction\n »; auto p1 = std::make_shared<Objet>(); std::cout << p1.use_count() << ’\n’; std::cout << p1.use_count() << ’\n’; Nous obtenons conceptuellement : La destruction n’intervient pas lorsque p2 ou p3 disparaissent mais intervient uniquement lorsque le dernier propriétaire, p1, est détruit. • Un shared_ptr utilise un mécanisme de comptage de propriétaires. • La ressource reste vivante tant que le nombre de propriétaires est supérieur à zéro. • Lorsque le dernier propriétaire disparaît, la ressource est automatiquement détruite. Copier ou déplacer un shared_ptr Un shared_ptr peut être copié : auto p1 = std::make_shared<int>(42);
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr et après cette copie on a : Les deux pointeurs sont propriétaires cependant, on peut également considérer un déplacement de shared_ptr auto p3 = std::move(p2); Dans ce cas, la propriété détenue par p2 est transférée à p3 et p2 devient vide. Le déplacement n’ajoute donc pas un nouveau propriétaire mais transfère simplement une participation existante à la propriété partagée. Passer un shared_ptr à une fonction Comme avec unique_ptr, le type du paramètre doit exprimer l’intention de la fonction. Par exemple, si une fonction doit seulement utiliser l’objet sans participer à sa propriété, il est généralement inutile de lui transmettre un shared_ptr par valeur. Nous pouvons simplement écrire void afficher(const Point& point) auto p = std::make_shared<Point>(3.0, 4.0); En revanche, si la fonction doit conserver une propriété partagée, elle peut recevoir : void conserver(std::shared_ptr<Point> p) Dans ce cas, l’instruction copie le shared_ptr et le compteur de propriétaires augmente donc temporairement ou durablement selon ce que la fonction fait de cette copie. Contrairement à unique_ptr, aucun std::move n’est nécessaire pour partager la propriété. shared_ptr a un coût La propriété partagée est plus complexe que la propriété exclusive car un shared_ptr doit notamment gérer des informations permettant de connaître le nombre de propriétaires de la ressource. Ces informations sont conservées dans une structure associée, appelée bloc de contrôle. Ce bloc contient notamment les informations nécessaires au comptage des propriétaires. Nous pouvons représenter la situation ainsi :
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr shared_ptr −→ bloc de contrôle −→ ressource Plusieurs shared_ptr peuvent utiliser le même bloc de contrôle par conséquent, la copie et la destruction d’un shared_ptr nécessitent donc notamment la mise à jour du compteur cq qui fait que cette propriété partagée possède ainsi un coût supérieur à celui d’une propriété exclusive. • shared_ptr ne doit pas être considéré comme une version “plus puissante” de unique_ptr. • Il répond à un problème différent et possède un coût supplémentaire. • Lorsque la propriété exclusive suffit, unique_ptr doit généralement être préféré. Le problème des références circulaires Le comptage de références semble résoudre élégamment le problème de la propriété partagée mais il possède une faiblesse importante si nous considérons deux objets qui possèdent chacun l’autre. Soit la classe simplifiée : std::shared_ptr<Noeud> autre; std::cout << « Destruction du noeud\n »; Créons deux objets : auto a = std::make_shared<Noeud>(); auto b = std::make_shared<Noeud>(); nous obtenons une situation circulaire. a −→ objet A −→ objet B −→ objet A L’objet A possède indirectement une propriété sur B et l’objet B possède une propriété sur A. Lorsque les variables locales a et b disparaissent, les compteurs ne tombent pas à zéro parce ce que chaque objet est encore considéré comme propriétaire de l’autre. Nous obtenons donc un cycle de propriété : Les deux objets peuvent alors rester en mémoire alors que le programme ne peut plus les atteindre depuis ses variables ordinaires et on a une fuite de ressources.
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr Le problème vient de la propriété Il faut bien comprendre la nature du problème précédent et se poser la question si l’objet A doit réellement être propriétaire de B et vice-versa. Dans de nombreuses structures de données, la réponse est non, un objet peut avoir besoin de connaître un autre objet sans pour autant participer à sa propriété. Il faut donc pouvoir exprimer la relation suivante : Je connais cette ressource et je peux éventuellement y accéder, mais je n’en suis pas propriétaire. std::weak_ptr : observer sans posséder Un weak_ptr est associé à une ressource gérée par des shared_ptr, mais il ne participe pas à sa propriété donc il n’augmente pas le nombre de propriétaires. auto p = std::make_shared<int>(42); std::weak_ptr<int> w = p; Nous avons conceptuellement : La flèche en pointillés représente une relation d’observation et non de propriété, le compteur de propriétaires reste égal à 1 et non à 2. Pourquoi un weak_ptr ne peut-il pas être déréférencé directement ? std::weak_ptr<int> w; auto p = std::make_shared<int>(42); À l’intérieur du bloc, p est propriétaire de l’entier, lorsque le bloc se termine, p est détruit, il n’existe alors plus aucun propriétaire et l’entier est détruit. Cependant w existe toujours. Un weak_ptr peut donc désigner une ressource qui a déjà disparu. Pour cette raison, nous ne pouvons pas simplement écrire : comme avec un shared_ptr.
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr Tester l’existence de la ressource Un weak_ptr implémente la fonction expired() qui permet de savoir si la ressource existe encore. std::cout << « La ressource n’existe plus\n »; Mais lorsqu’on souhaite réellement utiliser la ressource, une autre méthode est généralement préférable, c’est la méthode lock(). La fonction w.lock() tente de créer un shared_ptr vers la ressource observée. Si celle-ci existe encore, le shared_ptr obtenu est valide sinon, il est vide. Nous pouvons donc écrire : if (auto p = w.lock()) std::cout << *p << ’\n’; std::cout << « La ressource n’existe plus\n »; Si la ressource existe, lock() crée temporairement un nouveau propriétaire et pendant la durée de vie de ce shared_ptr, la ressource ne peut pas disparaître. • Un weak_ptr n’est pas propriétaire de la ressource qu’il observe. • Pour utiliser cette ressource en toute sécurité, on peut appeler : qui retourne un shared_ptr. • Si la ressource existe encore, ce shared_ptr en garantit la durée de vie pendant son utilisation. Rompre une référence circulaire Revenons à nos deux objets se possédant l’un l’autre, au lieu d’écrire std::shared_ptr<Noeud> autre; dans les deux directions, l’une des relations peut être non propriétaire :
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr std::weak_ptr<Noeud> autre; Le principe général consiste à utiliser shared_ptr pour les relations qui représentent réellement une propriété et weak_ptr pour les relations qui ne font qu’observer un objet ce qui rompt le cycle de propriété. Les compteurs des shared_ptr peuvent revenir à zéro et les objets sont correctement détruits. Les trois modèles de relation Nous disposons maintenant de trois outils permettant d’exprimer des relations différentes entre un programme et une ressource. propriétaire exclusif propriétaire partagé Nous pouvons également résumer leur comportement : observation du contrôle partagé Quel pointeur intelligent choisir ? Le choix ne doit pas commencer par la question : Quel pointeur intelligent vais-je utiliser ? Il faut d’abord se demander : Ai-je réellement besoin d’une allocation dynamique ? Si la réponse est non, un objet automatique ordinaire est souvent préférable : Si une ressource dynamique est nécessaire, la question suivante est : Qui doit posséder cette ressource ? Nous pouvons alors raisonner ainsi : un seul propriétaire plusieurs propriétaires nécessaires observation sans propriété Dans le doute, lorsque la propriété exclusive est possible, unique_ptr constitue généralement le choix naturel. La propriété partagée ne doit être introduite que lorsqu’elle correspond réellement à la structure
Accueil
2026 – C++ Partie III std::shared_ptr et std::weak_ptr std::shared_ptr permet à plusieurs objets de partager la propriété d’une même ressource, cette dernière reste vivante tant qu’au moins un propriétaire existe et lorsque le dernier shared_ptr propriétaire disparaît, la ressource est automatiquement détruite. Cette gestion repose sur un comptage de propriétaires associé à un bloc de contrôle cependant, le comptage de références ne suffit pas à résoudre toutes les situations, dans certains cas des relations circulaires entre shared_ptr peuvent empêcher les compteurs d’atteindre zéro et provoquer une fuite de ressources. std::weak_ptr permet alors d’observer une ressource gérée par shared_ptr sans en devenir propriétaire. Nous pouvons finalement retenir les trois modèles fondamentaux : unique_ptr −→ propriété exclusive shared_ptr −→ propriété partagée weak_ptr −→ observation sans propriété Les pointeurs intelligents ne servent donc pas uniquement à remplacer new et delete mais ils permettent surtout d’exprimer explicitement la relation de propriété entre les objets et les ressources.
Accueil
P4-Chap-05 std::shared_ptr et std::weak_ptr
Accueil
Termes à ajouter au glossaire

Chapitre 6 - std::vector et les conteneurs modernes


Chapitre 6 – std::vector et les conteneurs modernes

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
6 std::vector et les conteneurs modernes 6.2.1 Créer un vector avec une taille initiale . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.2 Une taille gérée automatiquement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.5 Pourquoi la capacité est-elle supérieure à la taille ? . . . . . . . . . . . . . . . . . . . . . . . 6.13 vector et mémoire contiguë . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.14 Attention aux pointeurs et références après réallocation . . . . . . . . . . . . . . . . . . . . . 6.15 vector ou tableau dynamique manuel ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III std::vector et les conteneurs modernes
Accueil
std::vector et les conteneurs modernes Pourquoi des conteneurs ? Dans les premiers chapitres de la partie III du cours, nous avons appris à créer des tableaux dynamiques avec l’instruction double∗ p = new double[n]; puis à libérer la mémoire avec Cette approche permet de comprendre précisément le fonctionnement de l’allocation dynamique, ce qui est fondamental pour bien comprendre ce chapitre et les précédents sur les pointeurs intelligents. L’ennui c’est que cette méthode impose au programmeur de gérer lui-même les éléments suivants : • l’allocation de la mémoire ; • la libération de cette mémoire ; • la taille du tableau ; • les éventuels changements de taille ; • les déplacements. Depuis C++11, le C++ est dit « moderne » et fournit des classes spécialement conçues pour gérer des collections d’objets, ces objets sont appelés des conteneurs. Parmi eux, le plus important est le conteneur : Un vector représente une séquence dynamique d’objets stockés de manière contiguë en mémoire. Le std:: est un namespace de la standard library 1 Notre premier vector Pour pouvoir utiliser std::vector, il faut d’abord inclure : 1. std est l’abréviation de standard et désigne l’espace de noms (namespace) de la bibliothèque standard C++. STL signifie Standard Template Library. Historiquement, la STL est une bibliothèque générique fondée sur les templates, notamment à l’origine des conteneurs, des itérateurs et des algorithmes intégrés à la bibliothèque standard C++. Les termes STL et bibliothèque standard C++ ne sont donc pas strictement synonymes. 2026 – C++ Partie III std::vector et les conteneurs modernes au début du programme ou du fichier concerné. Un vector peut être créé sans devoir préciser sa taille : std::vector<double> valeurs; Cette instruction crée un objet de type : qui ne contient initialement aucun élément. Nous pouvons ensuite lui ajouter des éléments : valeurs.push_back(3.14); 2 valeurs.push_back(2.71); 3 valeurs.push_back(1.41); Le vector contient maintenant trois éléments et sa taille vaut : Il n’est donc pas nécessaire de connaître à l’avance le nombre d’éléments que contiendra finalement un vector car sa taille peut évoluer pendant l’exécution du programme. Créer un vector avec une taille initiale Si le nombre initial d’éléments est connu, il est également possible de le préciser lors de la construction std::vector<double> valeurs(1000); Cette instruction ne fixe cependant pas une taille maximale, elle crée immédiatement un vector contenant 1000 éléments. Le vector pourra ensuite continuer à grandir ou, au contraire, voir sa taille diminuer. valeurs.push_back(3.14); porte sa taille à 1001 éléments. Une taille gérée automatiquement Avec une allocation dynamique manuelle double∗ p = new double[1000]; la taille choisie lors de cette allocation ne peut pas être simplement augmentée. Pour obtenir une zone plus grande, il faudrait notamment allouer une nouvelle zone de mémoire, transférer les données et libérer l’ancienne zone, de plus, la libération de la mémoire reste sous la responsabilité du programmeur : Avec un vector, ces opérations de gestion de la mémoire sont prises en charge automatiquement et vector applique le principe RAII : lorsque l’objet valeurs est détruit, les ressources qu’il possède sont automatiquement libérées.
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes Accéder aux éléments Comme avec un tableau classique, nous pouvons utiliser l’opérateur [] : std::cout << valeurs[0] << ’\n’; Le premier élément possède l’indice 0 et le dernier élément possède l’indice valeur.size()−1. La taille d’un vector Un avantage fondamental de vector est qu’il connaît sa propre taille, nous pouvons donc écrire : std::cout << valeurs.size() << ’\n’; retourne le nombre d’éléments actuellement contenus dans le vector. Avec un pointeur brut : cette information n’existe pas dans le pointeur lui-même, c’est le programmeur qui doit généralement conserver la taille dans une autre variable mais avec vector, la taille fait partie de l’objet. Un vector possède deux notions différentes qui sont : La taille correspond au nombre d’éléments réellement présents, elle est donnée par : La capacité correspond au nombre d’éléments que le vector peut actuellement contenir dans la mémoire déjà réservée sans devoir effectuer une nouvelle allocation. Cette capacité est fournie par la fonction std::cout << v.size() << ’\n’; std::cout << v.capacity() << ’\n’; Puis ajoutons progressivement des éléments
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes for (int i = 0; i < 20; ++i) std::cout << « taille =  » La taille augmente d’une unité à chaque ajout mais la capacité, en revanche, n’augmente pas nécessairement d’une unité à chaque fois. Le vector réserve généralement de la mémoire supplémentaire afin d’éviter une allocation à chaque nouvel élément. Pourquoi la capacité est-elle supérieure à la taille ? Imaginons qu’un vector possède exactement autant de mémoire que nécessaire. Après : il faudrait réserver de la mémoire pour un élément et après : il faudrait réserver une nouvelle zone capable de contenir deux éléments et ainsi de suite. Une telle stratégie provoquerait un très grand nombre d’allocations et de copies ou déplacements. Pour éviter cela, vector réserve généralement davantage d’espace que nécessaire et la situation peut être représentée ainsi : éléments utilisés Les valeurs exactes de croissance de la capacité ne sont pas imposées par le langage et peuvent dépendre de l’implémentation. Lorsque la taille atteint la capacité disponible et qu’un nouvel élément doit être ajouté, le vector doit généralement obtenir une nouvelle zone de mémoire plus grande et ce mécanisme peut être résumé ainsi : ancienne zone de mémoire allocation d’une nouvelle zone plus grande copie ou déplacement des éléments libération de l’ancienne zone
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes Cette opération est appelée une réallocation et nous retrouvons ici directement la sémantique de déplacement étudiée dans le chapitre précédent. Si les éléments du vector peuvent être déplacés efficacement, leur transfert vers la nouvelle zone de mémoire peut être beaucoup moins coûteux qu’une copie complète. Observer l’adresse Nous pouvons observer les réallocations avec la fonction : qui retourne l’adresse du premier élément de la zone de stockage contiguë, par exemple 4 int main(int argc, const char ∗ argv[]) { for (int i = 0; i < 2000000000; ++i) if (i % 500000000 == 0) std::cout << « taille =  » << static_cast<const void∗>(v.data()) return EXIT_SUCCESS; taille = 1 capacite = 1 adresse = 0x1007c1c10 taille = 500000001 capacite = 536870912 adresse = 0x765c00000 3 taille = 1000000001 capacite = 1073741824 adresse = 0xe5c000000 4 taille = 1500000001 capacite = 2147483648 adresse = 0x7000000000 5 Program ended with exit code: 0 Lorsque l’adresse retournée par data() change, une nouvelle zone de mémoire a été utilisée. Cette expérience permet de voir concrètement que le vector gère dynamiquement son stockage. Lorsqu’on connaît approximativement le nombre d’éléments qu’un vector devra contenir, il est possible de réserver à l’avance une certaine capacité, par exemple
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes Cette instruction demande au vector de disposer d’une capacité suffisante pour au moins 1000 éléments ne crée pas 1000 éléments. Après cette instruction : peut toujours valoir : est au moins égal à : • reserve() modifie la capacité, pas la taille. • Il permet de limiter le nombre de réallocations lorsqu’on connaît à l’avance l’ordre de grandeur du nombre d’éléments à stocker. modifie au contraire le nombre d’éléments réellement contenus dans le vector. Après cette instruction : Les éléments nécessaires ont effectivement été créés. Il faut donc distinguer soigneusement : réserve de la mémoire pour au moins n éléments, fixe le nombre d’éléments à n.
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes Initialiser un vector Plusieurs formes d’initialisation sont possibles. Nous pouvons créer cinq entiers initialisés à zéro : std::vector<int> v(5); ou encore créer cinq entiers contenant tous la valeur 7 : std::vector<int> v(5, 7); Nous pouvons également créer directement une liste de valeurs : std::vector<int> v{10, 20, 30, 40}; Attention à la différence entre std::vector<int> v(5); std::vector<int> v{5}; La première crée cinq éléments, la seconde crée un seul élément contenant la valeur 5. Cette différence provient du mécanisme d’initialisation par liste. Nous pouvons naturellement utiliser un indice : for (std::size_t i = 0; i < v.size(); ++i) std::cout << v[i] << ’\n’; Mais le C++ moderne permet également une boucle basée directement sur les éléments : for (int valeur : v) std::cout << valeur << ’\n’; Cette construction est appelée range-based for et si nous voulons modifier directement les éléments du vector, nous pouvons utiliser une référence : for (int& valeur : v) Pour parcourir sans copie et sans modification : for (const int& valeur : v) std::cout << valeur << ’\n’; Cette dernière écriture devient particulièrement intéressante lorsque les éléments sont des objets de taille
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes Accès avec [] ou at() pour parcourir vector, cet accès ne vérifie pas que l’indice est valide et si l’indice est hors limites, le comportement du programme est indéfini. vector fournit également la méthode qui effectue une vérification sur l’indice, si l’indice est invalide, une exception de type : vérification supplémentaire Supprimer des éléments Le dernier élément peut être supprimé avec : La taille diminue alors d’une unité et pour supprimer tous les éléments on peut faire la taille vaut zéro, la capacité n’est cependant pas nécessairement réduite, le vector peut conserver sa zone de mémoire afin de pouvoir être rempli à nouveau efficacement. vector et mémoire contiguë Un vector stocke ses éléments de manière contiguë, donc pour : std::vector<int> v{10, 20, 30}; les éléments sont disposés conceptuellement comme : dans une même zone de mémoire. Cette propriété permet notamment : int∗ p = v.data();
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes std::cout << p[0] << ’\n’; std::cout << p[1] << ’\n’; La fonction data() est particulièrement utile lorsqu’une API ancienne attend un pointeur vers un tableau Attention aux pointeurs et références après réallocation La réallocation possède une conséquence très importante, considérons : Si une réallocation intervient, les éléments sont transférés vers une nouvelle zone de mémoire, donc l’ancien peut alors désigner une zone qui n’est plus valide. Le même problème peut concerner les références et les itérateurs vers les éléments. Il faut donc bien retenir : Une opération qui provoque une réallocation peut invalider les pointeurs, références et itérateurs désignant les éléments du vector. vector ou tableau dynamique manuel ? Établissons la comparaison double∗ p = new double[n]; std::vector<double> v(n); Le vector fournit notamment : • la gestion automatique de la mémoire ; • la connaissance de sa taille ; • le redimensionnement ; • l’ajout et la suppression d’éléments ;
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes • la copie correcte ; • le déplacement ; • une interface compatible avec les algorithmes de la bibliothèque standard. Dans la majorité des programmes modernes, un tableau dynamique manuel ne constitue donc plus le choix • Pour représenter une séquence dynamique d’objets, std::vector doit généralement être préféré à une allocation manuelle avec new[]. • L’étude de new[] reste cependant indispensable pour comprendre ce que le conteneur gère Les autres conteneurs std::vector n’est qu’un des conteneurs fournis par la bibliothèque standard. Parmi les autres conteneurs importants, nous rencontrerons notamment : • std::array : tableau de taille fixe ; • std::deque : séquence permettant notamment des insertions efficaces aux deux extrémités ; • std::list : liste doublement chaînée ; • std::set : ensemble de valeurs uniques ; • std::map : association entre clés et valeurs ; • std::unordered_map : association basée sur une table de hachage. Tous ces conteneurs répondent à des besoins différents, il n’est donc pas utile de les considérer comme des variantes interchangeables et le choix dépend essentiellement des opérations que le programme devra std::vector est un conteneur dynamique stockant ses éléments de manière contiguë qui applique le principe RAII et gère automatiquement sa mémoire. Sa taille est donnée par : et sa capacité par : Les deux notions sont différentes, on a également les méthodes 1. push_back() ajoute un élément. 2. reserve() réserve de la capacité sans modifier la taille. 3. resize() modifie le nombre d’éléments. Lorsque la capacité devient insuffisante, une réallocation peut être nécessaire et cette réallocation peut déplacer les éléments vers une nouvelle zone de mémoire et invalider les pointeurs « manuels », références et itérateurs qui les désignaient.
Accueil
2026 – C++ Partie III std::vector et les conteneurs modernes Dans la majorité des cas où une séquence dynamique est nécessaire, std::vector doit être préféré à une gestion manuelle avec new[] et delete[].
Accueil
P4-Chap-06 std::vector et les conteneurs modernes
Accueil
Termes à ajouter au glossaire

Chapitre 7 - Itérateurs et algorithmes de la bibliothèque standard

Chapitre 7 – Itérateurs et algorithmes de la bibliothèque standard

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
7 Itérateurs et algorithmes de la bibliothèque standard 7.1 Du conteneur à l’algorithme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.3.1 Pourquoi une position après le dernier élément ? . . . . . . . . . . . . . . . . . . . . 7.4 Parcourir un conteneur avec un itérateur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5 L’itérateur cache la structure du conteneur . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8 Les différentes catégories d’itérateurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9 Les algorithmes de la bibliothèque standard . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10 Rechercher un élément avec std::find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10.1 Que se passe-t-il si la valeur n’existe pas ? . . . . . . . . . . . . . . . . . . . . . . . . 7.12.1 Pourquoi std::sort ne fonctionne-t-il pas directement avec std::list ? . . . . . 7.13 Appliquer une opération à tous les éléments . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.14 Rechercher selon une condition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.15 Transformer une séquence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.16 Pourquoi utiliser un algorithme ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.17 Un algorithme peut travailler sur une partie du conteneur . . . . . . . . . . . . . . . . . . . 7.18 Itérateurs et invalidation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.19 La boucle for basée sur un intervalle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.20 Itérateurs, pointeurs et abstraction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard
Accueil
Itérateurs et algorithmes de la bibliothèque standard Du conteneur à l’algorithme Nous avons étudié std::vector et listé plusieurs autres conteneurs de la bibliothèque standard à la fin du chapitre précédent. Nous avons vu comment parcourir un vector à l’aide d’un indice : std::vector<int> valeurs{10, 20, 30, 40}; for (std::size_t i = 0; i < valeurs.size(); ++i) std::cout << valeurs[i] << ’\n’; Cette technique fonctionne très bien avec un vector, car celui-ci permet un accès direct à ses éléments par leur indice mais tous les conteneurs ne possèdent pas cette propriété. Une std::list, par exemple, ne permet pas d’écrire Les éléments d’une liste chaînée ne sont pas nécessairement placés les uns à la suite des autres en mémoire donc il nous faut donc un mécanisme plus général permettant de parcourir les éléments d’un conteneur sans dépendre directement de sa structure interne. Ce mécanisme repose sur les itérateurs. Qu’est-ce qu’un itérateur ? Un itérateur est un objet permettant de désigner une position dans une séquence et de progresser dans cette séquence, son utilisation rappelle fortement celle d’un pointeur. std::vector<int> valeurs{10, 20, 30, 40}; Nous pouvons obtenir un itérateur vers le premier élément avec auto it = valeurs.begin(); Puis accéder à l’élément désigné en déréférencant le pointeur : 2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard std::cout << *it << ’\n’; Le programme affiche : Comme avec un pointeur, l’opérateur * permet donc d’accéder à l’objet désigné par l’itérateur et nous pouvons incémenter l’itérateur : std::cout << *it << ’\n’; • Un itérateur est un objet permettant de désigner une position dans une séquence et de parcourir • Sa syntaxe ressemble souvent à celle d’un pointeur : // position suivante • Mais un itérateur n’est pas nécessairement un pointeur. Les conteneurs fournissent généralement deux fonctions membres fondamentales qui sont begin() retourne un itérateur désignant le premier élément, et end() retourne un itérateur représentant la position située juste après le dernier élément. std::vector<int> v{10, 20, 30, 40}; nous pouvons représenter la situation ainsi : La position représentée par end() ne contient aucun élément du conteneur et il ne faut donc jamais la
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard Pourquoi une position après le dernier élément ? Cette convention permet de décrire une séquence par une paire d’itérateurs : Le premier élément appartient à la séquence, quant’à la position end elle n’a pas d’élément, c’est un intervalle semi-ouvert et nous pouvons écrire for (int i = 0; i < n; ++i) est le premier indice valide tandis que : est la première position située après le dernier élément (ne contient rien). Parcourir un conteneur avec un itérateur Nous pouvons maintenant parcourir entièrement un vector : std::vector<int> valeurs{10, 20, 30, 40}; for (auto it = valeurs.begin(); it != valeurs.end(); std::cout << *it << ’\n’; Le fonctionnement est directement comparable à celui d’une boucle utilisant un indice : it = valeurs.begin() Mais l’itérateur possède un avantage essentiel c’est qu’il peut également fonctionner avec des conteneurs qui ne possèdent pas d’indice. L’itérateur cache la structure du conteneur Considérons maintenant une liste : std::list<int> valeurs{10, 20, 30, 40}; Nous pouvons écrire : for (auto it = valeurs.begin(); it != valeurs.end(); std::cout << *it << ’\n’;
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard Le code de parcours est pratiquement identique à celui utilisé pour un vector pourtant, les structures internes sont très différentes, pendant qu’un vector stocke ses éléments de manière contiguë, une liste chaînée est conceptuellement constituée de noeuds reliés entre eux. Passer à l’élément suivant. La manière d’effectuer ce passage dépend du type d’itérateur. Le programmeur peut donc parcourir différentes structures à travers une interface commune. Le type d’un itérateur Nous avons utilisé l’itérateur auto it = valeurs.begin(); parce que l’utilisation de auto est particulièrement pratique ici en effet, l’écriture du type réel peut être on pourrait écrire explicitement std::vector<int>::iterator it = valeurs.begin(); auto it = valeurs.begin(); est généralement plus lisible. Nous rencontrons ici une utilisation particulièrement naturelle de auto : le type exact existe et reste parfaitement déterminé à la compilation, mais il n’est pas nécessaire de le répéter. Itérateurs constants Si nous ne voulons pas permettre la modification des éléments par l’intermédiaire de l’itérateur, nous pouvons utiliser un itérateur constant, par exemple auto it = valeurs.cbegin(); et la limite correspondante sera Avec un tel itérateur :
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard std::cout << *it << ’\n’; Nous disposons donc notamment de : parcours permettant éventuellement la modification, parcours en lecture seule. Les différentes catégories d’itérateurs Tous les itérateurs ne possèdent pas les mêmes possibilités, certains permettent seulement d’avancer, par D’autres permettent de reculer : D’autres encore permettent un déplacement direct Ces différences proviennent directement de la structure du conteneur. Par exemple, un vector permet un accès direct à n’importe quel élément et son itérateur peut effectuer efficacement des opérations telles que : Une liste chaînée doit au contraire suivre successivement les liens entre les noeuds pour avancer, elle ne permet donc pas le même type d’accès direct. La bibliothèque standard définit plusieurs catégories d’itérateurs mais pour l’instant, l’idée essentielle est Tous les itérateurs permettent certaines opérations communes, mais certains offrent des possibilités Les algorithmes de la bibliothèque standard L’intérêt des itérateurs apparaît pleinement avec les algorithmes qui pour pouvoir être utilisés nécessite #include <algorithm> Un algorithme ne travaille pas nécessairement directement avec un conteneur mais travaille souvent avec un intervalle d’itérateurs. Par exemple :
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard décrit l’ensemble des éléments du vector. Cette séparation est fondamentale : conteneur −→ itérateurs −→ algorithme L’algorithme n’a donc pas nécessairement besoin de connaître la structure interne du conteneur. Rechercher un élément avec std::find std::vector<int> valeurs{10, 20, 30, 40}; Nous pouvons rechercher la valeur 30 avec : auto it = std::find( std::find parcourt l’intervalle : [valeurs.begin(), valeurs.end()) et recherche la valeur demandée. Si elle est trouvée, l’algorithme retourne un itérateur vers l’élément correspondant et donc nous pouvons if (it != valeurs.end()) std::cout << « Valeur trouvee :  » Que se passe-t-il si la valeur n’existe pas ? Si la valeur recherchée n’est pas trouvée, std::find retourne : if (it == valeurs.end()) std::cout << « Valeur absente\n »; Nous retrouvons donc encore une fois le rôle particulier de end() : Il représente la position située après le dernier élément et peut également servir à signaler qu’aucun élément valide n’a été trouvé.
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard Compter avec std::count permet de compter le nombre d’occurrences d’une valeur. Par exemple, std::vector<int> valeurs{2, 7, 2, 4, 2, 9}; auto nombre = std::count( La variable nombre contient alors L’algorithme effectue lui-même le parcours du conteneur. Trier avec std::sort Un vector peut être trié avec : std::vector<int> valeurs{8, 2, 9, 1, 5}; std::sort(valeurs.begin(), valeurs.end()); Après cette opération, le vector contient : L’algorithme modifie directement l’ordre des éléments. Pourquoi std::sort ne fonctionne-t-il pas directement avec std::list ? Essayons conceptuellement : std::list<int> valeurs{8, 2, 9, 1, 5}; std::sort(valeurs.begin(), valeurs.end()); Cette utilisation n’est pas valide. std::sort nécessite des itérateurs permettant un accès aléatoire efficace aux éléments et les itérateurs d’une std::list ne possèdent pas cette capacité. La liste fournit donc sa propre fonction membre qui est : Cet exemple montre que l’abstraction fournie par les itérateurs ne signifie pas que toutes les structures de données deviennent identiques mais que leurs propriétés fondamentales continuent à déterminer les opérations qui peuvent être réalisées efficacement.
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard Appliquer une opération à tous les éléments Considérons une opération très simple : afficher chaque élément. Une solution serait parcourir une boucle, cependant, la bibliothèque standard fournit également des algorithmes capables d’appliquer une opération à chaque élément. Historiquement, on rencontre notamment : Son utilisation devient particulièrement intéressante lorsqu’on peut définir directement l’opération à effectuer et c’est précisément l’un des rôles des expressions lambda que nous verrons dans le chapitre Rechercher selon une condition std::find recherche une valeur déterminée. Par exemple, on peut vouloir rechercher • le premier nombre négatif ; • le premier nombre supérieur à 100 ; • le premier objet satisfaisant une certaine condition. La bibliothèque fournit pour cela Cet algorithme doit recevoir une condition capable de répondre à la question Cet élément convient-il ? Là encore, les expressions lambda fourniront une manière particulièrement élégante d’écrire cette condition. Nous reporterons donc l’étude détaillée de find_if au chapitre suivant. Transformer une séquence Un autre algorithme important est : Il permet notamment de produire des valeurs transformées à partir des éléments d’une séquence. Nous pourrions par exemple vouloir transformer : L’algorithme doit alors savoir quelle transformation appliquer à chaque élément et une expression lambda 1 permettra par exemple d’exprimer directement : Nous reviendrons donc également sur std::transform après avoir étudié les lambdas. 1. Voir chapitre suivant
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard Pourquoi utiliser un algorithme ? Considérons la recherche manuelle d’une valeur : bool trouve = false; for (int valeur : valeurs) La même intention peut être exprimée avec : auto it = std::find( L’intérêt n’est pas simplement de réduire le nombre de lignes, l’expression : indique immédiatement l’intention de rechercher un élément Le détail de la boucle disparaît du code principal donc les algorithmes permettent ainsi d’écrire du code à un niveau d’abstraction plus élevé. Un algorithme peut travailler sur une partie du conteneur Puisque les algorithmes travaillent sur des intervalles d’itérateurs, ils ne sont pas limités à l’ensemble du std::vector<int> v{10, 20, 30, 40, 50}; Nous pouvons rechercher uniquement dans une partie du vector : auto debut = v.begin() + 1; auto it = std::find(debut, fin, 30); L’intervalle considéré est : et correspond ici aux valeurs : La valeur 50 n’appartient pas à l’intervalle examiné et cela montre pourquoi les algorithmes travaillent avec des positions plutôt qu’avec le conteneur complet.
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard Itérateurs et invalidation Nous avons déjà rencontré le problème de la réallocation d’un vector. std::vector<int> v{10, 20, 30}; auto it = v.begin(); Si une opération ultérieure provoque une réallocation : l’ancienne zone de stockage peut être abandonnée au profit d’une nouvelle et dans ce cas, l’itérateur it devient invalide, donc il ne doit plus être utilisé. Nous retrouvons exactement le même phénomène que pour un pointeur ou une référence vers un élément du vector et les règles précises d’invalidation dépendent du conteneur et de l’opération effectuée. • Un itérateur n’assure pas à lui seul que l’objet désigné continuera d’exister à la même position. • Certaines modifications du conteneur peuvent invalider ses itérateurs. • Dans un vector, une réallocation invalide les itérateurs désignant ses éléments. La boucle for basée sur un intervalle Nous avons déjà utilisé : for (const int& valeur : valeurs) std::cout << valeur << ’\n’; Cette syntaxe paraît très différente d’une boucle utilisant explicitement des itérateurs et conceptuellement, elle repose pourtant sur le même principe qui est parcourir une séquence entre son début et sa fin. Nous pouvons donc voir la boucle : for (const auto& valeur : valeurs) comme une forme particulièrement pratique lorsque nous voulons simplement traiter successivement tous les éléments sans avoir besoin de manipuler explicitement leur position. Itérateurs, pointeurs et abstraction Il existe une parenté importante entre un pointeur et un itérateur. Avec un tableau classique nous avons : int t[] = {10, 20, 30};
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard nous pouvons écrire : auto it = v.begin(); L’itérateur généralise en quelque sorte certaines propriétés du pointeur, mais il les généralise à des structures qui ne sont pas forcément contiguës en mémoire donc c’est cette abstraction qui permet aux algorithmes génériques de fonctionner avec de nombreux types de conteneurs. Un itérateur représente une position dans une séquence. Les fonctions membres : délimitent généralement l’intervalle des éléments d’un conteneur où begin() désigne le premier élément et end() représente la position située immédiatement après le dernier élément et ne doit jamais être déréférencé. Les algorithmes de la bibliothèque standard utilisent fréquemment un intervalle semi-ouvert Nous avons vu les algorithmes suivant Les algorithmes séparent l’opération à effectuer de la structure concrète du conteneur mais les possibilités d’un algorithme restent cependant liées aux capacités des itérateurs qui lui sont fournis. Les itérateurs peuvent être invalidés lorsque le conteneur est modifié. Nous disposons maintenant des deux premières composantes de la programmation générique : Il reste à étudier une manière particulièrement puissante de fournir aux algorithmes le comportement qu’ils doivent appliquer aux éléments. Ce sera l’objet du chapitre suivant : les expressions lambda.
Accueil
P4-Chap-07 Itérateurs et algorithmes de la bibliothèque standard
Accueil
2026 – C++ Partie III Itérateurs et algorithmes de la bibliothèque standard
Accueil
Termes à ajouter au glossaire

Chapitre 8 - Les expressions lambda

Chapitre 8 – Les expressions lambda

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
8 Les expressions lambda 8.1 Pourquoi les expressions lambda ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.2 Une solution avec une fonction classique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.3 Notre première expression lambda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.8 Le rôle des crochets : les captures . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.11 Modifier une variable extérieure . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.12 Capturer plusieurs variables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.13 Captures générales avec [=] et [&] . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.14 Capture par valeur et mutable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.18 Trier selon notre propre critère . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.20 Transformer des éléments avec std::transform . . . . . . . . . . . . . . . . . . . . . . . . . 8.22 Lambda ou fonction classique ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.23 Lambda et programmation moderne . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III Les expressions lambda
Accueil
Les expressions lambda Pourquoi les expressions lambda ? Dans le chapitre précédent, nous avons rencontré des algorithmes capables d’effectuer des opérations sur les éléments d’un conteneur et certain de ces algorithmes savent directement ce qu’ils doivent rechercher, on peut prendre comme exemple std::vector<int> valeurs{10, 20, 30, 40}; auto it = std::find( Ici, l’algorithme recherche simplement la valeur 30, mais supposons maintenant que nous voulions rechercher : • le premier nombre supérieur à 25 ; • le premier nombre négatif ; • le premier nombre pair ; • le premier objet satisfaisant une condition particulière. L’algorithme ne peut pas deviner la condition que nous voulons appliquer mais on peut lui fournir un comportement à adopter Une solution avec une fonction classique Supposons que nous voulions rechercher le premier entier supérieur à 25. Nous pouvons commencer par écrire une fonction : bool superieurA25(int x) std::vector<int> valeurs{10, 20, 30, 40}; auto it = std::find_if( 2026 – C++ Partie III Les expressions lambda L’algorithme std::find_if applique successivement la fonction superieurA25() aux éléments du conteneur, conceptuellement cela donne : La recherche s’arrête donc sur la valeur 30, qui correspond, mais la fonction n’est peut-être utilisée qu’une seule fois et uniquement à cet endroit du programme, mais pour rentabiliser l’opération, le C++ moderne permet d’écrire cette petite fonction directement là où elle est nécessaire. Notre première expression lambda La fonction vue ci-dessus, bool superieurA25(int x) peut être remplacée par Il s’agit d’une expression lambda qui permet écrire directement : auto it = std::find_if( La lambda représente ici le comportement que std::find_if doit appliquer à chaque élément. Anatomie d’une lambda On peut distinguer trois parties principales :
Accueil
2026 – C++ Partie III Les expressions lambda La forme générale est donc : [captures](parametres) Une lambda est un objet Une expression lambda ne représente pas simplement une syntaxe abrégée pour déclarer une fonction ordinaire. Lorsqu’une lambda est rencontrée, le compilateur crée un type particulier, appelé type de fermeture (closure type), par exemple : auto estPair = [](int x) Puis appeler cet objet comme une fonction : std::cout << estPair(8) << ’\n’; std::cout << estPair(9) << ’\n’; Nous obtenons respectivement : L’objet estPair est donc appelable (callable) et son type exact est généré par le compilateur et c’est pourquoi auto est particulièrement naturel dans ce cas • Une expression lambda crée un objet appelable d’un type généré par le compilateur. • Une lambda peut donc être stockée dans une variable et appelée avec la syntaxe habituelle : Paramètres d’une lambda Les paramètres fonctionnent de manière très similaire à ceux d’une fonction ordinaire, nous trouvons des lambdas sans paramètre : std::cout << « Bonjour\n »;
Accueil
2026 – C++ Partie III Les expressions lambda des lambdas avec un paramètre : et des lambdas avec plusieurs paramètres : [](double a, double b) Nous pouvons par exemple écrire : auto addition = [](double a, double b) std::cout << addition(3.5, 2.0) << ’\n’; Dans de nombreux cas, le compilateur peut déduire le type retourné, ainsi : auto carre = [](double x) retourne naturellement un double mais nous pouvons également préciser explicitement le type : auto carre = [](double x) -> double est appelée type de retour final (trailing return type). Cette syntaxe n’est pas propre aux lambdas : elle peut également être utilisée avec des fonctions ordinaires mais dans les cas simples, la déduction automatique est généralement préférable. Le rôle des crochets : les captures La partie la plus particulière d’une lambda est probablement Il s’agit de la liste de captures et jusqu’à présent, elle était vide. Considérons maintenant :
Accueil
2026 – C++ Partie III auto test = [](int x) Les expressions lambda Cette écriture ne fonctionne pas car la variable locale limite est extérieure à la lambda. Pour que la lambda puisse utiliser cette variable locale, nous devons indiquer comment elle doit être capturée. Nous pouvons capturer limite par valeur auto test = [limite](int x) La lambda reçoit alors sa propre représentation de la valeur de limite au moment où elle est créée, par auto test = [limite](int x) std::cout << test(30) << ’\n’; La lambda continue ici à utiliser la valeur capturée lors de sa création, c.-à-d. et test(30) retourne donc true. • Une capture par valeur réalise une capture de l’état de la variable au moment de la création de l’objet lambda. • Une modification ultérieure de la variable extérieure ne modifie pas la valeur capturée. Capture par référence Nous pouvons au contraire capturer la variable par référence, auto test = [&limite](int x)
Accueil
2026 – C++ Partie III Les expressions lambda Cette fois, la lambda utilise la variable extérieure elle-même. auto test = [&limite](int x) std::cout << test(30) << ’\n’; retourne maintenant false car la lambda voit en effet la nouvelle valeur 100. Modifier une variable extérieure Une capture par référence permet également de modifier la variable extérieure, par exemple : auto incrementer = [&compteur]() std::cout << compteur << ’\n’; Le programme affiche : La lambda travaille directement sur la variable compteur existante. Capturer plusieurs variables Plusieurs variables peuvent être indiquées dans les crochets : auto appartient = [minimum, maximum](int x) return x >= minimum && x <= maximum; Il est également possible de mélanger capture par valeur et capture par référence : auto test = [limite, &compteur](int x)
Accueil
2026 – C++ Partie III Les expressions lambda • limite est capturée par valeur ; • compteur est capturé par référence. Captures générales avec [=] et [&] Le C++ fournit deux syntaxes permettant de définir un mode de capture par défaut, avec les variables locales utilisées par la lambda sont capturées par valeur, par exemple auto test = [=](int x) return x >= minimum && x <= maximum; les variables locales utilisées sont capturées par référence, exemple : Ces écritures sont pratiques, mais une capture explicite peut rendre plus visible ce dont dépend réellement une lambda, par exemple : indique immédiatement au lecteur quelles variables extérieures sont utilisées. Capture par valeur et mutable Par défaut, une variable capturée par valeur ne peut pas être modifiée par le corps d’une lambda ordinaire, auto f = [compteur]()
Accueil
2026 – C++ Partie III Les expressions lambda permet de modifier l’état interne de l’objet lambda, en notant auto f = [compteur]() mutable std::cout << compteur << ’\n’; Nous pouvons appeler : Mais la variable extérieure : La lambda modifie sa propre copie capturée, et non la variable originale. Capture et durée de vie La distinction entre capture par valeur et capture par référence possède également une conséquence importante sur la durée de vie. Une capture par valeur appartient à l’objet lambda et une capture par référence désigne au contraire un objet extérieur. Il faut donc s’assurer que cet objet existe toujours lorsque la lambda Considérons conceptuellement une lambda conservée après la disparition de la variable qu’elle avait capturée par référence, la référence contenue dans la lambda ne désignerait alors plus un objet valide. Nous retrouvons exactement un problème déjà rencontré avec les pointeurs et les références : Une référence n’assure pas la durée de vie de l’objet qu’elle désigne. Cette question devient particulièrement importante lorsqu’une lambda est stockée pour être exécutée plus std::find_if et les lambdas Nous pouvons maintenant revenir à l’algorithme et rechercher le premier entier supérieur à une limite choisie avec le bout de code :
Accueil
2026 – C++ Partie III Les expressions lambda std::vector<int> valeurs{10, 20, 30, 40, 50}; auto it = std::find_if( Si un élément satisfait la condition, it le désigne. Nous pouvons donc écrire : if (it != valeurs.end()) std::cout << *it << ’\n’; Le programme affiche la valeur L’algorithme fournit le mécanisme de recherche et la lambda fournit le critère de recherche. std::for_each et les lambdas applique une opération à chaque élément d’un intervalle, par exemple : std::vector<int> valeurs{10, 20, 30, 40}; std::cout << x << ’\n’; La lambda est appelée successivement avec chaque élément et nous pourrions également modifier les éléments si nous le désirons
Accueil
2026 – C++ Partie III Les expressions lambda Le conteneur contient alors : Dans un cas aussi simple, une boucle basée sur un intervalle serait souvent plus lisible : for (int& x : valeurs) Le fait qu’un algorithme existe ne signifie donc pas qu’il doit toujours être utilisé et comme souvent, la lisibilité du code reste un critère essentiel. Trier selon notre propre critère std::sort(v.begin(), v.end()); trie les valeurs dans l’ordre croissant lorsque leur opérateur < possède cette signification, mais nous pouvons fournir notre propre critère pour trier des entiers dans l’ordre décroissant par exemple : La lambda reçoit ici deux éléments et doit indiquer si le premier doit être placé avant le second. Nous pouvons ainsi définir des ordres beaucoup plus intéressants. Considérons la classe Mesure, et la déclaration : std::vector<Mesure> mesures; Nous pouvons trier les objets selon leur valeur : [](const Mesure& a, const Mesure& b) return a.valeur < b.valeur;
Accueil
2026 – C++ Partie III Les expressions lambda [](const Mesure& a, const Mesure& b) return a.temps < b.temps; Le même conteneur peut donc être trié selon différents critères sans modifier la classe Mesure. C’est une utilisation particulièrement importante des lambdas. Transformer des éléments avec std::transform L’algorithme std::tranform permet d’appliquer une transformation à une séquence. std::vector<double> valeurs{1.0, 2.0, 3.0, 4.0}; std::vector<double> carres(valeurs.size()); Nous pouvons écrire : Le conteneur carres contient alors : Nous pouvons lire cette instruction comme : Pour chaque élément compris entre valeurs.begin() et valeurs.end(), appliquer la transformation définie par la lambda et placer le résultat à partir de carres.begin(). Les lambdas génériques Depuis C++14, les paramètres d’une lambda peuvent eux-mêmes utiliser auto, par exemple : auto addition = [](auto a, auto b) Nous pouvons alors écrire :
Accueil
2026 – C++ Partie III Les expressions lambda Le compilateur génère les comportements nécessaires selon les types utilisés, une telle lambda est appelée Nous retrouvons ici l’esprit de la programmation générique et des templates, mais avec une syntaxe particulièrement compacte. Lambda ou fonction classique ? Les lambdas ne remplacent pas les fonctions ordinaires et une fonction classique reste généralement préférable lorsqu’une opération : • possède une signification propre dans le programme ; • est utilisée à plusieurs endroits ; • devient suffisamment longue ou complexe ; • mérite un nom explicite. Une lambda est particulièrement adaptée lorsqu’un petit comportement est nécessaire localement, par [](const Mesure& a, const Mesure& b) return a.valeur < b.valeur; Le critère de tri est visible exactement à l’endroit où il est utilisé et il n’est pas nécessaire de rechercher ailleurs dans le programme une fonction auxiliaire pour comprendre le comportement de l’algorithme. Lambda et programmation moderne Les expressions lambda sont devenues un élément important du C++ moderne et sont notamment utilisées • les algorithmes de la bibliothèque standard ; • les traitements de conteneurs ; • la programmation événementielle ; • l’exécution différée ; • certaines formes de programmation parallèle. Elles permettent surtout de considérer un comportement comme une valeur que l’on peut créer, stocker et
Accueil
2026 – C++ Partie III Les expressions lambda Une expression lambda permet de définir localement un objet appelable. Sa forme générale est : [captures](parametres) Les paramètres fonctionnent de manière analogue à ceux d’une fonction ordinaire. Le type de retour peut généralement être déduit automatiquement ou être indiqué explicitement : [](double x) -> double La liste de captures permet d’utiliser des variables locales extérieures à la lambda, une capture par valeur : conserve une valeur dans l’objet lambda. Une capture par référence : permet d’accéder à la variable extérieure elle-même. définissent respectivement une capture par défaut par valeur ou par référence. permet de modifier l’état des valeurs capturées par valeur dans l’objet lambda et enfin, les lambdas s’associent naturellement aux algorithmes tels que : L’algorithme définit alors le mécanisme général tandis que la lambda définit le comportement particulier à
Accueil
P4-Chap-08 Les expressions lambda
Accueil
2026 – C++ Partie III Les expressions lambda
Accueil
Termes à ajouter au glossaire

Chapitre 9 - Quelques outils du C++ moderne

Chapitre 9 – Quelques outils du C++ moderne

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
9 Quelques outils du C++ moderne 9.2 auto : laisser le compilateur déduire un type . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3 Quand auto devient réellement utile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.5 nullptr : représenter un pointeur nul . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.6 Pourquoi nullptr est préférable à 0 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.7 La boucle for basée sur un intervalle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.8 Valeur, référence et référence constante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.9 enum class : des énumérations plus sûres . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.10 Pourquoi enum class est plus sûr . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.11 constexpr : calculer à la compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.14 Décomposer une structure . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.15 Structured bindings et conteneurs associatifs . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.16 Représenter l’absence d’une valeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.21 Des outils différents pour rendre l’intention explicite . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III Quelques outils du C++ moderne
Accueil
Quelques outils du C++ moderne Le C++ moderne ne se résume pas à quelques grandes nouveautés comme les pointeurs intelligents, la sémantique de déplacement, les conteneurs ou les expressions lambda, et depuis C++11, puis avec les versions suivantes du langage, de nombreux outils plus modestes ont également été introduits. Pris séparément, certains peuvent sembler n’être plus des commodités syntaxiques que des nouveautés fondamentales. Ensemble, ils permettent cependant d’écrire un code : • plus expressif ; • plus facile à maintenir. Plusieurs de ces outils ont déjà été rencontrés dans les chapitres précédents et nous allons maintenant préciser leur rôle. auto : laisser le compilateur déduire un type Le mot-clé auto demande au compilateur de déduire le type d’une variable à partir de son initialisation, Le compilateur déduit respectivement On pourrait donc écrire : L’intérêt de auto n’est donc pas particulièrement évident dans ces exemples ! 2026 – C++ Partie III Quelques outils du C++ moderne Quand auto devient réellement utile Considérons un itérateur std::vector<double>::iterator it = valeurs.begin(); Nous pouvons écrire plus simplement : auto it = valeurs.begin(); Le type reste parfaitement déterminé à la compilation et il n’y a aucun typage dynamique car le compilateur connaît exactement le type de it. Ensuite, nous avons également rencontré auto avec les expressions lambda : auto carre = [](double x) Le type exact de l’objet lambda est généré par le compilateur, auto est donc ici particulièrement naturel. • auto ne signifie pas que la variable n’a pas de type, le type existe toujours et il est déterminé à la compilation, C++ reste un langage fortement typé. auto signifie simplement : Déduis le type à partir de l’expression d’initialisation. Ne pas abuser de auto L’utilisation de auto doit améliorer la lisibilité et non la diminuer. auto temperature = 21.5; est parfaitement compréhensible mais lorsqu’il devient difficile de déterminer ce que représente une expression, écrire explicitement le type peut rendre le programme plus clair. Il ne faut donc pas considérer auto comme une règle obligatoire, c’est un outil. nullptr : représenter un pointeur nul Dans du code ancien, on rencontre fréquemment Le C++ moderne introduit
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne Nous écrivons donc : Le mot-clé nullptr représente explicitement une valeur de pointeur nul. Pourquoi nullptr est préférable à 0 Considérons deux fonctions surchargées, void afficher(int x) std::cout << « entier\n »; void afficher(int* p) std::cout << « pointeur\n »; appelle la version recevant un entier, en revanche : désigne clairement une valeur de pointeur nul et permet de sélectionner la surcharge correspondant au • Dans du code C++ moderne, on utilise : pour représenter un pointeur nul. • On évite d’utiliser 0 ou NULL dans ce rôle, NULL est utilisé en C. La boucle for basée sur un intervalle Nous avons déjà rencontré la boucle : for (double x : valeurs) std::cout << x << ’\n’; Il s’agit d’une boucle for basée sur un intervalle (range-based for loop) qui permet de parcourir directement les éléments d’un conteneur.
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne Valeur, référence et référence constante Il faut cependant rester attentif à la manière dont la variable de boucle est déclarée, avec : for (double x : valeurs) chaque élément est copié dans x, avec for (double& x : valeurs) x est une référence vers l’élément du conteneur et donc nous pouvons donc modifier les éléments : for (double& x : valeurs) Si nous voulons seulement consulter les éléments sans les copier : for (const double& x : valeurs) std::cout << x << ’\n’; Cette dernière forme devient particulièrement intéressante pour les objets dont la copie pourrait être coûteuse. enum class : des énumérations plus sûres Le C++ possède depuis longtemps les énumérations classiques : Elles présentent cependant certaines limitations et le C++ moderne propose les énumérations fortement Nous utilisons alors Couleur c = Couleur::rouge; indique explicitement que rouge appartient à l’énumération Couleur.
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne Pourquoi enum class est plus sûr appartiennent à deux types différents et le compilateur empêche donc de les mélanger accidentellement, de plus, une valeur d’un enum class n’est pas implicitement convertie en entier. Si une conversion est réellement souhaitée, elle doit être exprimée explicitement, par exemple : int n = static_cast<int>(Etat::marche); Nous retrouvons ici les conversions explicites déjà étudiées précédemment. constexpr : calculer à la compilation permet d’indiquer qu’une valeur ou un calcul peut être évalué lors de la compilation lorsque les conditions nécessaires sont réunies, par exemple : constexpr double pi = 3.141592653589793; La valeur est connue à la compilation. Une fonction constexpr Une fonction peut également être déclarée constexpr : constexpr int carre(int x) Nous pouvons alors écrire : constexpr int n = carre(5); Ici, le calcul peut être effectué pendant la compilation, mais une fonction constexpr peut également être appelée avec une valeur connue seulement pendant l’exécution :
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne Dans ce cas, le calcul est naturellement effectué pendant l’exécution. • constexpr ne signifie pas simplement : Cette fonction est toujours exécutée à la compilation. • Une fonction constexpr peut également être utilisée pendant l’exécution, le contexte dans lequel elle est appelée détermine si une évaluation à la compilation est requise ou possible. Les structured bindings Depuis C++17, il est possible de décomposer certains objets en plusieurs variables. std::pair<int, double> mesure{12, 3.7}; Traditionnellement, nous pourrions écrire : int numero = mesure.first; double valeur = mesure.second; Avec une structured binding : auto [numero, valeur] = mesure; Les deux éléments sont directement associés à deux noms. Décomposer une structure Nous obtenons directement deux variables :
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne contenant respectivement les deux composantes de p. Comme avec les autres constructions du langage, il faut être attentif à la différence entre copie et référence. permet de travailler directement sur les membres de p. Structured bindings et conteneurs associatifs Les structured bindings deviennent particulièrement agréables avec des conteneurs comme std::map, par std::map<std::string, double> temperatures; temperatures[« Geneve »] = 21.5; temperatures[« Lausanne »] = 20.8; Nous pouvons parcourir la map ainsi for (const auto& [ville, temperature] : temperatures) Cette écriture évite de manipuler explicitement les membres first et second des paires contenues dans la Représenter l’absence d’une valeur Une fonction ne peut pas toujours produire un résultat valable. Supposons une fonction recherchant une valeur dans un ensemble de données, nous pourrions être tentés de retourner : Aucune valeur n’a été trouvée. Mais que se passe-t-il si -1 est lui-même un résultat parfaitement valable ? Nous mélangeons alors deux informations différentes : • la valeur du résultat ; • l’existence ou l’absence d’un résultat. Le C++17 propose pour cela :
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne Pour utiliser std::optional, nous incluons Une fonction peut alors retourner : std::optional<double> Cette fonction peut fournir un double, mais elle peut également ne fournir aucune valeur. std::optional<double> chercher( const std::vector<double>& valeurs, for (double x : valeurs) return std::nullopt; représente explicitement l’absence de valeur. Nous pouvons écrire : auto resultat = chercher(valeurs, 10.0); std::cout << *resultat << ’\n’; std::cout << « Aucun resultat\n »; teste si l’objet contient une valeur et si une valeur existe :
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne permet d’y accéder. Nous retrouvons volontairement une syntaxe rappelant celle des pointeurs. value() et value_or() Un optional possède également la fonction membre : std::cout << resultat.value() << ’\n’; Si aucune valeur n’est présente, value() déclenche une exception de type : std::bad_optional_access Nous reviendrons précisément sur le mécanisme des exceptions dans la partie consacrée aux exceptions. Une autre fonction membre permet de fournir une valeur de remplacement : double x = resultat.value_or(0.0); Si resultat contient une valeur, celle-ci est utilisée, sinon, 0.0 est utilisé. optional ou exception ? L’absence d’une valeur n’est pas nécessairement une erreur, par exemple, rechercher un élément dans un ensemble peut parfaitement échouer dans le fonctionnement normal d’un programme. Dans ce cas, un type comme : exprime naturellement : Il peut y avoir un résultat ou ne pas y en avoir. Une exception correspond à une autre idée : elle permet de signaler qu’une situation exceptionnelle empêche le déroulement normal d’une opération, cette distinction sera étudiée plus précisément dans le chapitre consacré aux exceptions. Des outils différents pour rendre l’intention explicite Les outils étudiés dans ce chapitre répondent à des problèmes différents. auto permet au compilateur de déduire un type lorsque son écriture explicite n’apporte rien ou devient nullptr exprime sans ambiguïté l’absence d’un objet désigné par un pointeur. La boucle for basée sur un intervalle exprime directement l’idée de parcourir les éléments d’un ensemble. enum class permet de représenter des ensembles de valeurs symboliques avec un typage plus strict.
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne constexpr permet d’exprimer qu’un calcul peut participer à une évaluation à la compilation. Les structured bindings permettent de donner directement des noms aux différentes composantes d’un objet décomposable. permet d’exprimer dans le système de types qu’une valeur peut être présente ou absente. Le point commun de ces outils n’est donc pas simplement leur date d’apparition dans le langage mais ils permettent au programmeur d’exprimer plus précisément son intention. Le C++ moderne fournit de nombreux outils qui complètent les mécanismes fondamentaux du langage. permet la déduction du type à partir d’une initialisation. représente explicitement un pointeur nul. for (const auto& x : conteneur) permet un parcours compact et sûr des éléments d’un conteneur. Les énumérations : offrent un typage plus strict que les anciennes énumérations. permet de rendre certains calculs utilisables lors de la compilation. Les structured bindings : auto [a, b] = objet; permettent de décomposer certains objets.
Accueil
2026 – C++ Partie III Quelques outils du C++ moderne représente explicitement une valeur qui peut être présente ou absente. Ces outils illustrent une évolution importante du C++ moderne, celle d’exprimer davantage d’informations directement dans le langage et dans les types, plutôt que de reposer sur des conventions implicites.
Accueil
P4-Chap-09 Quelques outils du C++ moderne
Accueil
Termes à ajouter au glossaire

Chapitre 10 - Rule of Zero

Chapitre 10 – Rule of Zero

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }
10.2 Une classe qui gère directement de la mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . 10.4 Il faut programmer la copie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.6 L’arrivée de la sémantique de déplacement . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.7 Un constructeur de déplacement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.9 De la Rule of Three à la Rule of Five . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.10 Changeons maintenant la représentation interne . . . . . . . . . . . . . . . . . . . . . . . . . 10.12 Le constructeur de copie a disparu . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.13 L’affectation par copie a disparu . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.14 Le déplacement a également disparu . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.16 Zero ne signifie pas zéro constructeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.17 Déléguer la gestion des ressources . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.18 Un exemple avec une matrice . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.21 Rule of Zero et encapsulation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.22 Moins de code, moins de possibilités d’erreur . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.23 Faut-il alors encore apprendre la gestion manuelle ? . . . . . . . . . . . . . . . . . . . . . . . 10.24 De la gestion manuelle à la Rule of Zero . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.25 Une règle de conception, pas une obligation . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.26 Conclusion de la Partie IV . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2026 – C++ Partie III
Accueil
Dans les chapitres précédents, nous avons étudié plusieurs mécanismes liés à la gestion des ressources : • allocation et libération de mémoire ; • constructeurs et destructeurs ; • copie d’objets ; • sémantique de déplacement ; • pointeurs intelligents ; • conteneurs de la bibliothèque standard. Nous allons maintenant réunir ces notions autour d’un principe de conception particulièrement important Son idée peut sembler surprenante : Une classe devrait, lorsque cela est possible, ne définir elle-même aucune fonction membre spéciale destinée à gérer ses ressources. Pour comprendre pourquoi cette règle est intéressante, revenons d’abord à la gestion manuelle d’une Une classe qui gère directement de la mémoire Considérons une classe représentant un tableau dynamique : explicit Tableau(std::size_t n) donnees(new double[n]) 2026 – C++ Partie III Cette classe possède directement une zone de mémoire obtenue avec : et elle doit donc la libérer dans son destructeur avec l’instruction À première vue, tout semble correct, mais la copie d’un objet va immédiatement poser un problème. Le problème de la copie Si nous ne définissons pas nous-mêmes le constructeur de copie, le compilateur effectue une copie membre par membre. Si le membre est copié sans difficulté, par contre le membre : est lui aussi simplement copié. C’est donc l’adresse contenue dans le pointeur qui est copiée et pas le tableau. Nous obtenons conceptuellement : a.donnees −−−−+ +−−−−> zone de memoire b.donnees −−−−+ Les deux objets possèdent alors un pointeur vers la même zone de mémoire et lorsque les objets sont détruits, chacun exécute : La même zone de mémoire risque donc d’être libérée deux foi et le comportement du programme devient Il faut programmer la copie Notre classe doit donc définir un constructeur de copie réalisant une copie profonde :
Accueil
2026 – C++ Partie III Tableau(const Tableau& autre) : taille(autre.taille), donnees(new double[autre.taille]) for (std::size_t i = 0; i < taille; ++i) donnees[i] = autre.donnees[i]; Cette fois, chaque objet possède sa propre zone mémoire : a.donnees −−−−−−−−> zone A b.donnees −−−−−−−−> zone B Mais ce n’est pas encore suffisant, il faut également traiter correctement l’affectation Il faut donc réfléchir à l’opérateur d’affectation par copie : Tableau& operator=(const Tableau& autre); Historiquement, cette situation conduit à la Rule of Three c.-à-d. lorsqu’une classe doit définir explicitement l’une des trois fonctions suivantes : • constructeur de copie ; • opérateur d’affectation par copie ; il faut généralement se demander si elle ne doit pas définir également les deux autres. Tableau(const Tableau& autre); Tableau& operator=(const Tableau& autre); Ces trois fonctions sont liées au fait que la classe gère directement une ressource. • La Rule of Three n’est pas une règle syntaxique imposée par le compilateur. • C’est une règle de conception. • Si une classe doit gérer explicitement la destruction d’une ressource, sa copie demande généralement elle aussi une attention particulière.
Accueil
2026 – C++ Partie III L’arrivée de la sémantique de déplacement Depuis C++11, nous disposons également de la sémantique de déplacement et notre classe doit donc également considérer : Tableau(Tableau&& autre); Tableau& operator=(Tableau&& autre); Le constructeur de déplacement peut transférer la ressource au lieu de copier tous les éléments. autre.donnees −−−−> zone memoire autre.donnees −−−−> nullptr nouveau.donnees −−> zone memoire La zone mémoire n’a pas été copiée mais sa propriété a été transférée. Un constructeur de déplacement Une implémentation possible serait Tableau(Tableau&& autre) noexcept : taille(autre.taille), donnees(autre.donnees) autre.donnees = nullptr; Le nouvel objet récupère : puis l’ancien objet est placé dans un état valide ne possédant plus la ressource : autre.donnees = nullptr; Lorsque son destructeur sera appelé : delete[] autre.donnees; la valeur nullptr ne provoquera aucune libération de la ressource transférée. Tableau(Tableau&& autre) noexcept
Accueil
2026 – C++ Partie III indique que cette opération ne doit pas laisser s’échapper d’exception. Un déplacement consistant simplement à transférer un pointeur et quelques valeurs peut effectivement être réalisé sans allocation supplémentaire. Cette propriété est également importante pour certains conteneurs de la bibliothèque standard, qui peuvent préférer déplacer les objets lors d’une réallocation lorsqu’ils savent que leur déplacement ne déclenchera pas d’exception. Nous reviendrons sur les exceptions dans le chapitre qui leur est consacré. De la Rule of Three à la Rule of Five Avec la sémantique de déplacement, les trois fonctions deviennent cinq : Tableau(const Tableau& autre); Tableau& operator=(Tableau&& autre) noexcept; Tableau& operator=(const Tableau& autre); Tableau(Tableau&& autre) noexcept; La classe qui gère directement une ressource doit donc considérer : • sa destruction ; • son affectation par copie ; • son déplacement ; • son affectation par déplacement. Cela représente beaucoup de code et chaque ligne supplémentaire de gestion manuelle constitue une possibilité supplémentaire d’introduire une erreur. Changeons maintenant la représentation interne Notre classe contient actuellement : Mais la bibliothèque standard possède déjà un objet conçu pour gérer un tableau dynamique : Nous pouvons donc réécrire notre classe :
Accueil
2026 – C++ Partie III std::vector<double> donnees; explicit Tableau(std::size_t n) Et quelque chose de remarquable vient de se produire. Le destructeur a disparu Nous n’avons plus besoin d’écrire : Pourquoi ? Parce que lorsque l’objet Tableau est détruit, son membre : est automatiquement détruit. Or std::vector sait déjà libérer la mémoire qu’il possède donc nous déléguons donc la gestion de cette ressource au vector. Le constructeur de copie a disparu Le compilateur peut générer automatiquement le constructeur de copie de Tableau qui copie les membres Le membre principal est : std::vector<double> donnees; Or std::vector sait déjà se copier correctement donc chaque Tableau obtient donc son propre ensemble L’affectation par copie a disparu peut utiliser l’opérateur d’affectation généré automatiquement pour Tableau car celui-ci utilise l’opérateur d’affectation de ses membres et le vector sait déjà effectuer correctement cette opération.
Accueil
2026 – C++ Partie III Le déplacement a également disparu Nous n’avons plus besoin d’écrire manuellement : Tableau(Tableau&& autre); Tableau& operator=(Tableau&& autre); Le compilateur peut générer les opérations appropriées à partir de celles des membres de la classe donc le vector sait déjà gérer efficacement le déplacement de ses ressources et de ce fait, nous bénéficions de sa sémantique de déplacement sans devoir manipuler nous-mêmes son allocation dynamique. Nous arrivons maintenant à la : L’idée consiste à concevoir autant que possible les classes de manière à ne pas devoir écrire explicitement les fonctions membres spéciales de gestion des ressources. std::vector<double> donnees; explicit Tableau(std::size_t n) ne définit explicitement : • aucun destructeur ; • aucun constructeur de copie ; • aucun opérateur d’affectation par copie ; • aucun constructeur de déplacement ; • aucun opérateur d’affectation par déplacement. D’où le nom Rule of Zero. Zero ne signifie pas zéro constructeur Il faut éviter une confusion importante. La Rule of Zero ne signifie absolument pas qu’une classe ne doit posséder aucun constructeur car notre classe possède bien :
Accueil
2026 – C++ Partie III explicit Tableau(std::size_t n) Ce constructeur exprime la manière dont un objet Tableau doit être créé et nous pouvons naturellement avoir d’autres constructeurs correspondant aux besoins de la classe. La Rule of Zero concerne les fonctions membres spéciales liées à la gestion de la durée de vie, de la copie et du déplacement des ressources. Déléguer la gestion des ressources La Rule of Zero repose sur une idée fondamentale : Une classe ne devrait pas gérer directement une ressource si elle peut confier cette responsabilité à un objet conçu pour cela. Pour de la mémoire dynamique, nous pouvons par exemple utiliser : Ces objets connaissent leurs propres règles de destruction, de copie ou de déplacement et notre classe peut alors se concentrer sur ce qu’elle représente réellement. Un exemple avec une matrice Considérons une classe représentant une matrice. Une implémentation utilisant directement la mémoire dynamique pourrait contenir : std::size_t colonnes; double* coefficients; Cette classe doit alors gérer explicitement la mémoire associée à : et donc réfléchir à sa destruction, sa copie et son déplacement. Une conception moderne peut utiliser : std::size_t colonnes; std::vector<double> coefficients;
Accueil
2026 – C++ Partie III Matrice(std::size_t l, std::size_t c) La classe Matrice ne gère plus directement l’allocation de mémoire car cette responsabilité appartient au : La classe peut maintenant se concentrer sur les opérations propres aux matrices : • accès à un coefficient ; • multiplication ; • calculs particuliers. La Rule of Zero est une conséquence naturelle de RAII. Avec une gestion manuelle : +−−−−> double∗ +−−−−> memoire dynamique la classe Matrice est directement responsable de la ressource +−−−−> vector<double> +−−−−> memoire dynamique la classe Matrice possède un objet RAII et le vector acquiert, possède et libère sa propre ressource, de plus la durée de vie de la mémoire dynamique est attachée à la durée de vie du vector, elle-même attachée à celle de la Matrice. Et avec un unique_ptr ? Toutes les ressources ne sont pas nécessairement représentées par un conteneur et une classe peut par std::unique_ptr<Ressource> ressource; Elle n’a pas besoin d’écrire un destructeur uniquement pour effectuer :
Accueil
2026 – C++ Partie III Le unique_ptr s’en charge automatiquement mais il faut cependant comprendre une conséquence importante, un unique_ptr n’est pas copiable. Une classe contenant un unique_ptr ne sera donc pas automatiquement copiable simplement parce que nous appliquons la Rule of Zero mais en revanche, elle pourra naturellement bénéficier de la sémantique de déplacement du unique_ptr. • La Rule of Zero ne signifie pas que toutes les classes deviennent automatiquement copiables • Les opérations possibles pour une classe dépendent naturellement des opérations possibles • C’est précisément ce que nous voulons : les propriétés de la classe découlent de la nature des objets qui la composent. Rule of Zero et encapsulation La Rule of Zero renforce également l’encapsulation. Une classe représentant une matrice ne devrait idéalement pas avoir pour préoccupation principale : Comment vais-je libérer ce tableau de double ? Mais devrait plutôt s’occuper de questions comme : Comment accéder à un coefficient ? Comment multiplier deux matrices ? Quelles dimensions rendent une opération possible ? La gestion technique de la mémoire est confiée à un composant spécialisé et la classe peut alors se concentrer sur son propre domaine. Moins de code, moins de possibilités d’erreur Comparons les deux approches. Avec une gestion manuelle, il faut correctement programmer : Matrice(const Matrice&); Matrice& operator=(const Matrice&); Matrice(Matrice&&) noexcept;
Accueil
2026 – C++ Partie III Matrice& operator=(Matrice&&) noexcept; Il faut notamment éviter : • les fuites de mémoire ; • les doubles libérations ; • les copies superficielles accidentelles ; • les problèmes d’auto-affectation ; • les états invalides après déplacement ; • les erreurs lors d’une exception. std::vector<double> coefficients; la bibliothèque standard prend en charge une grande partie de ces problèmes et le code devient non seulement plus court, mais surtout plus facile à rendre correct. Faut-il alors encore apprendre la gestion manuelle ? On pourrait se demander pourquoi nous avons étudié ainsi que les destructeurs, la copie profonde et la sémantique de déplacement, si le but final consiste souvent à ne plus les manipuler directement mais la réponse est fondamentale : Sans comprendre la gestion manuelle des ressources, il est difficile de comprendre : • ce que RAII résout ; • pourquoi un destructeur est nécessaire ; • pourquoi une copie superficielle peut être dangereuse ; • pourquoi unique_ptr interdit la copie ; • ce que signifie réellement déplacer une ressource ; • pourquoi vector simplifie autant la conception d’une classe. Le C++ moderne ne fait donc pas disparaître ces mécanismes mais permet de les encapsuler dans des composants fiables afin de ne pas devoir les reprogrammer continuellement. De la gestion manuelle à la Rule of Zero Nous pouvons maintenant résumer l’évolution : Gestion directe d’une ressource
Accueil
2026 – C++ Partie III destruction + copie + deplacement objets gestionnaires vector, string, unique_ptr, … La Rule of Zero ne supprime donc pas les principes précédents mais en est l’aboutissement. Une règle de conception, pas une obligation Comme la Rule of Three et la Rule of Five, la Rule of Zero n’est pas une règle imposée par le langage. Certaines classes doivent réellement gérer des ressources de bas niveau, c’est notamment le cas lorsqu’on écrit soi-même un composant RAII chargé d’encapsuler une ressource particulière. Une telle classe peut avoir besoin d’un destructeur et de fonctions spéciales explicitement définies. Mais une fois cette ressource correctement encapsulée, les classes de plus haut niveau devraient autant que possible utiliser cet objet plutôt que reprendre elles-mêmes la gestion de la ressource. On peut donc distinguer deux rôles : 1. les classes chargées de gérer une ressource ; 2. les classes qui utilisent ces gestionnaires de ressources. La Rule of Zero concerne particulièrement le second cas. Conclusion de la Partie IV Nous avons commencé cette partie en étudiant RAII : La durée de vie d’une ressource est associée à la durée de vie d’un objet. Nous avons ensuite étudié la sémantique de déplacement, les pointeurs intelligents, les conteneurs, les itérateurs, les algorithmes et les expressions lambda. La Rule of Zero permet maintenant de réunir plusieurs de ces idées. Le but du C++ moderne n’est pas de faire disparaître les ressources, les pointeurs, les allocations, les copies ou les déplacements, mais consiste notamment à construire des abstractions qui permettent de gérer ces mécanismes une fois correctement, puis de les utiliser sans avoir à les reproduire partout dans le programme.
Accueil
2026 – C++ Partie III • Une bonne connaissance de la gestion des ressources permet de comprendre comment l’effectuer correctement. • Une bonne conception permet ensuite, très souvent, de ne plus avoir à l’effectuer directement. • C’est l’idée fondamentale de la Rule of Zero.
Accueil
P4-Chap-10 Rule of Zero
Accueil
Termes à ajouter au glossaire

Partie V - Projets C++

Annexes

Annexes au cours C++

Compiler un projet C++ avec un Makefile

Compiler un projet C++ avec un Makefile

Document réservé

Vous consultez actuellement la présentation publique de ce chapitre.

Les documents PDF complets, comprenant les développements théoriques, les exemples détaillés et les exercices, sont disponibles sur demande.

Pour obtenir un accès, contactez-moi via la page Contact en indiquant les domaines qui vous intéressent (C++, ESP-IDF, électronique, etc.).

.document-reserve { background-color: #171717; color: #f2f2f2; padding: 28px 32px; margin: 25px 0; border-left: 4px solid #d94f8a; border-radius: 6px; font-size: 1.05rem; font-family: "Nunito", sans-serif; line-height: 1.6; } .document-reserve h3 { margin-top: 0; margin-bottom: 20px; color: #ffffff; font-size: 1.35rem; } .document-reserve p { margin-bottom: 16px; } .document-reserve a { color: #ff6fa8; font-weight: 600; text-decoration: none; } .document-reserve a:hover { text-decoration: underline; }

Téléchargement d’un Makefile générique adaptable

.cpp-download-item { font-family: "Nunito", sans-serif; } .cpp-download-title { display: block; font-size: 16px; color: blue; text-decoration: none; } .cpp-download-title:hover { text-decoration: underline; } .cpp-download-description { font-size: 14px; }
Makefile
Fichier Makefile que vous pourrez adapter à votre projet