Le langage C++, créé au début des années 1980 par le chercheur Bjarne Stroustrup chez Bell Labs, est introduit initialement comme une extension du langage C avec lequel il est intrinsèquement lié. Le langage C est un langage dit de “bas niveau”, en étant proche du matériel (processeur, mémoire) particulièrement adapté pour coder des applications efficaces liées au système d’exploitation. Le langage C++ a été introduit pour préserver les possibilités du langage C, tout en l’étendant à des mécanismes de structuration et d’abstraction pour la description de logiciels de grande envergure.
Le C++ se distingue des autres langages de programmation par sa capacité unique à combiner performance bas niveau et abstraction de haut niveau. Héritier direct du C, il permet un contrôle précis de la mémoire et du matériel, indispensable dans les domaines où l’efficacité est critique (systèmes embarqués, calcul scientifique, moteurs de jeu, etc.). Contrairement à des langages comme Python ou Java, qui reposent sur une machine virtuelle ou un interpréteur qui ajoute une étape d’indirection lors de l’exécution, le C++ est un langage compilé qui produit du code machine optimisé directement lu et exécuté par le processeur, garantissant ainsi une exécution très rapide.
Une autre spécificité majeure du C++ est son support simultané de plusieurs manières de programmer, appelées paradigmes de programmation :
Ce mélange de paradigmes fait aujourd’hui du C++ un langage reconnu comme extrêmement flexible, capable de s’adapter à une grande variété de contextes. Il reste incontournable pour des domaines où la performance et la maîtrise fine de la mémoire sont essentielles, comme les moteurs de jeux, les logiciels embarqués, la simulation numérique, le calcul haute performance ou encore la finance.
Le langage C++ continue à intégrer des évolutions régulières.
auto (déduction
de type), l’apparition des pointeurs intelligents (ex.
std::unique_ptr, std::shared_ptr) et des
fonctions lambdas (fonctions anonymes).Le C++ est actuellement l’un des langages indispensables lorsqu’il s’agit de concevoir des applications à fortes contraintes de performance, de temps réel ou de calcul intensif.
On considère le programme C++ suivant:
// bibliothèque standard pour les entrées/sorties
#include <iostream>
int main() {
// affichage d’un message sur la ligne de commande
std::cout << "Hello, world!" << std::endl;
// fin du programme
return 0;
}#include <iostream>
std::cin, std::cout,
etc.).int main()
main.int indique que la fonction main
renvoie un entier au système d’exploitation (0 en cas de succès, une
autre valeur en cas d’erreur).std::cout << "Hello, world!" << std::endl;
std::cout est le flux de sortie standard (en général
l’écran).<< permet d’envoyer des données dans
le flux."Hello, world!" est une chaîne de caractères.std::endl insère un saut de ligne et force l’affichage
immédiat.return 0;
Rem. Chaque instruction se termine par un point virgule “;” en C++. L’indentation et les sauts de lignes sont optionnels, ils sont utiles pour la lisibilité du programme mais ne changent pas sa structure.
Pour transformer le fichier source C++ (par exemple
hello.cpp) en un exécutable, on utilise un
compilateur C++. Sous Linux ou macOS, les compilateurs
les plus courants sont :
Supposons que le fichier s’appelle hello.cpp. Tapez en
ligne de commande dans le répertoire contenant le fichier
hello.cpp
g++ hello.cpp -o hellog++ : lance le compilateur C++.hello.cpp : fichier source à compiler.-o hello : option qui indique le nom de l’exécutable
produit (hello).L’execution du programme se réalise avec la commande
./helloCe qui doit afficher le résultat suivant
Hello, world!En C++, une variable est une zone de mémoire qui
contient une valeur et qui est identifiée par un nom.
Chaque variable a un type qui définit la nature des
valeurs qu’elle peut contenir (entiers, nombres à virgule, texte,
etc.).
#include <iostream>
#include <string>
int main() {
int age = 20; // entier
float taille = 1.75f; // nombre à virgule (simple précision)
double pi = 3.14159; // nombre à virgule (double précision)
std::string nom = "Alice"; // chaîne de caractères
std::cout << "Nom : " << nom << std::endl;
std::cout << "Age : " << age << std::endl;
std::cout << "Taille : " << taille << " m" << std::endl;
std::cout << "Valeur de pi : " << pi << std::endl;
return 0;
}Vous utiliserez principalement deux types fondamentaux dans vos codes :
int : nombre entier (integer). Sur nos machines,
un int est encodé sur 4 octets.
int entier = 325;float : nombre à virgule flottante, dit à “simple précision”. Encodé sur 4 octets.
float reel = 3.2f;Vous rencontrerez également les types suivants :
bool : valeur booléenne (true ou
false). Introduit par C++ (absent du C), il rend le code
plus lisible qu’un entier.
bool estEtudiant = true;double : nombre à virgule flottante à “double précision”, encodé sur 8 octets.
double pi = 3.14159;Par défaut, un nombre décimal sans suffixe est interprété comme un
double.
> Dans notre contexte, on utilisera plus souvent des
float pour rester compatibles avec la carte
graphique.
char : caractère (1 octet). La correspondance entre valeurs et caractères est donnée par la table ASCII.
char initiale = 'A';Un char peut également être utilisé pour manipuler
directement la mémoire au niveau de l’octet.
Division entière vs division flottante
Lorsqu’on divise deux entiers, le résultat est tronqué (division euclidienne) :
int a = 5 / 2; // vaut 2
int b = 5 % 2; // vaut 1 (reste de la division)Pour obtenir un résultat décimal, il faut qu’au moins un des opérandes soit flottant :
float c = 5 / 2.0f; // 2.5
float d = 5.0f / 2; // 2.5
float e = float(5) / 2; // 2.5Le mot-clé auto
Il permet au compilateur de déduire automatiquement le type :
auto a = 5; // int
auto b = 8.4f; // float
auto c = 4.2; // doublePour des types simples, il reste préférable d’indiquer explicitement
le type pour plus de lisibilité.
auto est surtout utile pour des fonctions génériques ou des
types complexes.
int compteur; // non initialisé
compteur = 10; // affectation d'une valeur plus tardAttention : une variable non initialisée contient une valeur indéfinie et ne doit pas être utilisée avant affectation.
const)En C++, une variable peut être déclarée constante
grâce au mot-clé const. Une telle variable doit être
initialisée au moment de sa déclaration et ne
peut plus être modifiée ensuite.
const int joursParSemaine = 7;
const float pi = 3.14159f;
int main() {
std::cout << "Pi = " << pi << std::endl;
// pi = 3.14; // ERREUR : impossible de modifier une constante
return 0;
}En C++, il est fréquent de convertir une valeur d’un type vers un autre : on appelle cela un cast (conversion de type).
Exemples : conversions implicites et explicites
int i = 3;
float f = i; // conversion implicite : int -> float
double d = 3.9;
int j = (int)d; // cast C-style : tronque la partie décimale (narrowing)
int k = static_cast<int>(d); // cast C++-style : recommandé car plus sécuriséConventions de conversion :
static_cast<T>(expr) pour les
conversions entre types numériques et entre pointeurs compatibles.(int)d est la notation C-style de cast ; on peut
également trouver int(d) qui est la forme fonctionnelle
(function-style) de cast. Pour les types fondamentaux, les deux se
comportent de façon équivalente (tronquent la partie décimale).double -> int tronque, un entier non signé
peut déborder (overflow).reinterpret_cast<T>(expr),
qui réinterprète la représentation binaire d’un objet comme un autre
type. C’est une opération bas‑niveau, potentiellement dangereuse
(risques d’alignement, d’aliasing ou comportement indéfini) ;
n’utilisez‑la que pour de l’interopérabilité ou de la lecture/écriture
binaire clairement documentés.Cette notion est utile pour contrôler explicitement les conversions et éviter des comportements surprises lors des opérations arithmétiques ou des passages d’arguments.
printf et
scanf (hérités du C)En plus de std::cout et std::cin, C++
conserve les fonctions classiques du langage C :
printf (print formatted) :
pour un affichage formaté.scanf (scan formatted) : pour
une lecture formatée.Elles sont définies dans l’en-tête <cstdio> (ou
<stdio.h> en C). Leur usage repose sur des
spécificateurs de format (%d,
%f, %s, etc.) qui indiquent le type de la
variable.
printf#include <cstdio>
int main() {
int age = 20;
float taille = 1.75f;
printf("Age : %d ans, taille : %.2f m\n", age, taille);
return 0;
}Sortie :
Age : 20 ans, taille : 1.75 m
%d : entier (int)%f : flottant (float ou
double)%.2f : flottant affiché avec deux décimalesscanf#include <cstdio>
int main() {
int age;
printf("Entrez votre age : ");
scanf("%d", &age); // & = adresse mémoire
printf("Vous avez %d ans.\n", age);
return 0;
}Dans scanf, il est nécessaire de fournir
l’adresse de la variable (ici &age),
car la fonction modifie directement sa valeur.
printf / scanf)| Spécificateur | Type attendu | Exemple d’utilisation | Résultat affiché |
|---|---|---|---|
%d |
entier signé (int) |
printf("%d", 42); |
42 |
%u |
entier non signé (unsigned) |
printf("%u", 42u); |
42 |
%f |
flottant (float ou double) |
printf("%f", 3.14); |
3.140000 |
%.nf |
flottant avec n décimales |
printf("%.2f", 3.14159); |
3.14 |
%e |
flottant en notation scientifique | printf("%e", 12345.0); |
1.234500e+04 |
%c |
caractère (char) |
printf("%c", 'A'); |
A |
%s |
chaîne de caractères (char*) |
printf("%s", "Bonjour"); |
Bonjour |
%x |
entier en hexadécimal (min.) | printf("%x", 255); |
ff |
%X |
entier en hexadécimal (maj.) | printf("%X", 255); |
FF |
%p |
adresse mémoire (pointeur) | printf("%p", &a); |
0x7ffee3c8a4 |
%% |
caractère % littéral |
printf("%%d"); |
%d |
En C++, la librairie standard (STL, Standard Template
Library) définit plusieurs conteneurs permettant de stocker des
ensembles de valeurs.
Parmi eux, deux structures sont particulièrement importantes :
std::array<T, N> : tableau
statique de taille fixe.
N doit être connue à la
compilation et ne peut pas changer.std::vector<T> : tableau
dynamique.
T var[N]) :
size(),
push_back, etc.).std::vector#include <iostream>
#include <vector>
int main() {
// Création d’un vecteur vide d’entiers
std::vector<int> vec;
// Ajout d’éléments (redimensionnement automatique)
vec.push_back(5);
vec.push_back(6);
vec.push_back(2);
// Taille du vecteur
std::cout << "Le vecteur contient " << vec.size() << " éléments" << std::endl;
// Accès aux éléments par indice
std::cout << "Premier élément : " << vec[0] << std::endl;
// Modification d’un élément
vec[1] = 12;
// Parcours du vecteur avec une boucle
for (int k = 0; k < vec.size(); ++k) {
std::cout << "Élément " << k << " : " << vec[k] << std::endl;
}
return 0;
}Accéder à un élément en dehors des bornes est un comportement indéfini (undefined behavior), qui peut provoquer un crash du programme.
// Mauvais usage : peut provoquer une erreur ou un comportement imprévisible
// vec[8568] = 12;
// Accès sécurisé (vérification des bornes)
vec.at(0) = 42;Un vecteur peut être redimensionné dynamiquement avec la méthode
.resize(N) :
vec.resize(10000);
// Les anciens éléments sont conservés
// Les nouveaux sont initialisés à 0std::array, std::vector et tableaux C#include <array>
#include <vector>
#include <iostream>
int main() {
// Tableau C classique
int tab[5] = {1, 2, 3, 4, 5};
// std::array (statique, taille fixe)
std::array<int, 5> arr = {1, 2, 3, 4, 5};
// std::vector (dynamique, taille variable)
std::vector<int> vec = {1, 2, 3};
std::cout << "Taille du tab : " << 5 << " (fixe, connue à la compilation)" << std::endl;
std::cout << "Taille du array : " << arr.size() << std::endl;
std::cout << "Taille du vector : " << vec.size() << std::endl;
vec.push_back(10); // possible
// arr.push_back(10); // impossible : taille fixe
// tab.push_back(10); // impossible : fonction inexistante
return 0;
}T var[N]) : simples, mais
limités et peu sûrs.std::array<T, N> : tableau
statique, taille fixée à la compilation, stocké sur la pile (stack
memory).std::vector<T> : tableau
dynamique, taille modifiable, stocké sur le tas (heap memory).std::array pour des petites tailles fixes
connues à l’avance.std::vector pour des données dont la taille
peut varier au cours du programme.Structure générale :
if (condition) {
// instructions si la condition est vraie
} else {
// instructions si la condition est fausse
}Les accolades {} sont optionnelles si
une seule instruction est présente :
if (x > 0)
std::cout << "x est positif" << std::endl;Exemple :
int age = 20;
if (age >= 18) {
std::cout << "Vous êtes majeur." << std::endl;
} else {
std::cout << "Vous êtes mineur." << std::endl;
}Structure générale :
if (condition1) {
// instructions
} else if (condition2) {
// instructions
} else {
// instructions par défaut
}Exemple :
int note = 15;
if (note >= 16)
std::cout << "Très bien !" << std::endl;
else if (note >= 10)
std::cout << "Suffisant." << std::endl;
else
std::cout << "Échec." << std::endl;Structure générale :
while (condition) {
// instructions répétées tant que la condition est vraie
}Exemple :
int i = 0;
while (i < 5) {
std::cout << "i = " << i << std::endl;
i++;
}Structure générale :
do {
// instructions exécutées au moins une fois
} while (condition);Exemple :
int i = 0;
do {
std::cout << "i = " << i << std::endl;
i++;
} while (i < 5);Structure générale :
for (initialisation; condition-continuation; incrément) {
// instructions répétées
}Exemple :
for (int i = 0; i < 5; i++) {
std::cout << "i = " << i << std::endl;
}Structure générale :
for (type variable : conteneur) {
// instructions utilisant la variable
}Exemple :
#include <vector>
int main() {
std::vector<int> valeurs = {1, 2, 3, 4, 5};
for (int v : valeurs)
std::cout << v << std::endl;
}Le switch permet de tester plusieurs valeurs d’une même
variable entière ou caractère.
Structure générale :
switch (variable) {
case valeur1:
// instructions
break;
case valeur2:
// instructions
break;
default:
// instructions par défaut
}Limitation : switch ne fonctionne qu’avec des types
entiers ou caractères.
Le mot-clé break évite d’exécuter les blocs suivants.
std::mapUn std::map est un conteneur associatif de la
bibliothèque standard qui stocke des paires clé/valeur triées par clé.
Chaque clé est unique et permet d’accéder efficacement à la valeur
correspondante (recherche en O(log n)).
#include <map>operator< par défaut).operator[] crée une valeur par
défaut si la clé n’existe pas ; find permet de tester
l’existence sans créer.Exemple simple : compter la fréquence de mots
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> counts;
// Insertion / incrémentation
counts["pomme"] = 5;
counts["banane"] = 4;
counts["avocat"] = 8;
counts["pomme"]++;
// Parcours et affichage
for (auto pair : counts) {
std::cout << pair.first << " : " << pair.second << std::endl;
}
// Affiche:
// avocat : 8
// banane : 4
// pomme : 6
// Recherche sans création
auto it = counts.find("orange");
if (it == counts.end())
std::cout << "orange non trouvé" << std::endl;
// Suppression
counts.erase("banane");
return 0;
}Remarques :
operator[] pour insérer/accéder rapidement.
Une entrée est automatiquement créé si la clé est absente.find.En C++, la durée de vie (ou scope) d’une variable
est déterminée par le bloc d’instructions dans lequel
elle est déclarée.
Un bloc est défini par des accolades { ... }.
La variable existe depuis sa déclaration jusqu’à l’accolade fermante
} du bloc.
int main()
{
if (true) {
int x = 5; // x est défini dans le bloc "if"
std::cout << x << std::endl;
}
// Ici, x n’existe plus : il est détruit à la fin du bloc
}int main()
{
int x = 5; // x est défini dans le bloc de la fonction main()
if (true) {
std::cout << x << std::endl; // x peut être utilisé dans ce sous-bloc
}
// x existe toujours jusqu’à la fin de main()
}if ou une boucle reste accessible
jusqu’à la fin de la fonction.Cela est possible dans des sous-blocs :
int x = 5;
{
int x = 10; // autorisé mais à éviter, car peu lisible
std::cout << x << std::endl; // affiche 10
}
std::cout << x << std::endl; // affiche 5En C++, une fonction est un bloc de code
réutilisable qui effectue une tâche particulière.
La syntaxe générale est la suivante :
typeRetour nomFonction(type nomArgument1, type nomArgument2, ...)
{
// corps de la fonction
return valeur;
}int addition(int a, int b)
{
return a + b;
}void.En C++, il est nécessaire que la signature d’une fonction soit déclarée avant son utilisation. Sinon, il y aura une erreur de compilation.
int addition(int a, int b)
{
return a + b;
}
int main()
{
int c = addition(5, 3); // OK
}int addition(int a, int b); // Déclaration
int main()
{
int c = addition(5, 3); // OK
}
int addition(int a, int b) // Définition
{
return a + b;
}int main()
{
int c = addition(5, 3); // ERREUR : addition n’est pas encore déclarée
}
int addition(int a, int b)
{
return a + b;
}normÉcrivons une fonction qui calcule la norme
euclidienne d’un vecteur 3D de coordonnées
(x, y, z) :
#include <iostream>
#include <cmath> // pour std::sqrt
float norm(float x, float y, float z)
{
return std::sqrt(x*x + y*y + z*z);
}
int main()
{
std::cout << "Norme de (1,0,0) : " << norm(1.0f, 0.0f, 0.0f) << std::endl;
std::cout << "Norme de (0,3,4) : " << norm(0.0f, 3.0f, 4.0f) << std::endl;
std::cout << "Norme de (1,2,2) : " << norm(1.0f, 2.0f, 2.0f) << std::endl;
}Sortie attendue :
Norme de (1,0,0) : 1
Norme de (0,3,4) : 5
Norme de (1,2,2) : 3
float x2 = x * x;float y = std::sqrt(x);float y = std::pow(x, p);Ne pas utiliser ^ ni ** en C++ : ce ne sont
pas des opérateurs de puissance.
En C++, plusieurs fonctions peuvent partager le même nom tant que leurs paramètres diffèrent. C’est ce qu’on appelle la surcharge (overloading).
#include <iostream>
#include <cmath>
// Résout ax + b = 0
float solve(float a, float b) {
return -b / a;
}
// Résout ax^2 + bx + c = 0 (une racine)
float solve(float a, float b, float c) {
float delta = b*b - 4*a*c;
return (-b + std::sqrt(delta)) / (2*a);
}
int main() {
float x = solve(1.0f, 2.0f); // Appelle la 1ère version
float y = solve(1.0f, 2.0f, 1.0f); // Appelle la 2ème version
std::cout << "Solution linéaire : " << x << std::endl;
std::cout << "Solution quadratique : " << y << std::endl;
}return) ou
être void.En C++, les arguments des fonctions sont passés par
copie par défaut :
- Les modifications faites dans la fonction restent locales.
- Pour de gros objets (vecteurs, tableaux, structures), la copie peut
être coûteuse en performance.
#include <iostream>
void increment(int a) {
a = a + 1;
}
int main() {
int x = 3;
increment(x);
std::cout << x << std::endl; // affiche 3 (x n'est pas modifié)
}Ici, la variable x n’est pas modifiée dans
main car increment travaille sur une
copie.
On peut utiliser le symbole & dans la signature pour
passer un argument par référence.
Cela permet de modifier directement la variable originale :
#include <iostream>
void increment(int& a) {
a = a + 1;
}
int main() {
int x = 3;
increment(x);
std::cout << x << std::endl; // affiche 4 (x est modifié)
}Une référence est un alias : la fonction accède à la variable originale et non à une copie.
std::vectorConsidérons une fonction qui multiplie les valeurs d’un vecteur :
#include <iostream>
#include <vector>
std::vector<float> generate_vector(int N)
{
std::vector<float> values(N);
for (int k = 0; k < N; ++k)
values[k] = k / (N - 1.0f);
return values;
}
void multiply_values(std::vector<float> vec, float s)
{
for (int k = 0; k < vec.size(); ++k) {
vec[k] = s * vec[k];
}
std::cout << "Last value in the function: " << vec.back() << std::endl;
}
int main()
{
int N = 101;
std::vector<float> vec = generate_vector(N);
multiply_values(vec, 2.0f);
std::cout << "Last value in main: " << vec.back() << std::endl;
}Sortie attendue :
Last value in the function: 2
Last value in main: 1
Ici, vec est passé par copie à
multiply_values.
La modification est faite sur une copie locale, donc vec
dans main reste inchangé.
Modifions la signature pour passer le vecteur par référence :
void multiply_values(std::vector<float>& vec, float s)
{
for (int k = 0; k < vec.size(); ++k) {
vec[k] = s * vec[k];
}
std::cout << "Last value in the function: " << vec.back() << std::endl;
}Résultat attendu :
Last value in the function: 2
Last value in main: 2
Si l’on souhaite éviter la copie sans modifier le vecteur, on peut utiliser une référence constante :
float sum(std::vector<float> const& T) {
float value = 0.0f;
for (int k = 0; k < T.size(); k++)
value += T[k];
return value;
}Ce type de passage permet :
1. D’éviter la copie des données.
2. D’assurer que les valeurs ne seront pas modifiées dans la
fonction.
Bonne pratique : utiliser des références constantes pour les gros objets qui ne doivent pas être modifiés.
En C++, une classe (ou une struct) est un moyen de regrouper dans une même entité :
On parle alors d’objet pour désigner une instance de la classe.
#include <iostream>
#include <cmath>
// Déclaration d’une structure
struct vec3 {
float x, y, z;
};
int main()
{
// Création d’un vec3 non initialisé
vec3 p1;
// Création et initialisation d’un vec3
vec3 p2 = {1.0f, 2.0f, 5.0f};
// Accès et modification des attributs
p2.y = -4.0f;
std::cout << p2.x << "," << p2.y << "," << p2.z << std::endl;
return 0;
}En C++, les objets peuvent être définis avec le mot-clé
struct ou
class :
struct vec3 {
float x, y, z; // Par défaut : public
};
class vec3 {
public:
float x, y, z; // Doit être indiqué explicitement
};Différence principale :
En pratique :
struct pour des objets simples qui
agrègent des données publiques.class lorsque l’on souhaite encapsuler des
données privées avec des méthodes d’accès.Une classe peut définir des méthodes, c’est-à-dire des fonctions qui manipulent directement ses attributs.
#include <iostream>
#include <cmath>
struct vec3 {
float x, y, z;
float norm() const; // méthode qui ne modifie pas l’objet
void display() const; // idem
void normalize(); // méthode qui modifie (x,y,z)
};
// Implémentation des méthodes
float vec3::norm() const {
return std::sqrt(x * x + y * y + z * z);
}
void vec3::normalize() {
float n = norm();
x /= n;
y /= n;
z /= n;
}
void vec3::display() const {
std::cout << "(" << x << "," << y << "," << z << ")" << std::endl;
}
int main()
{
vec3 p2 = {1.0f, 2.0f, 5.0f};
// Norme
std::cout << p2.norm() << std::endl;
// Normalisation
p2.normalize();
// Affichage
p2.display();
return 0;
}this->, bien que ce soit possible.NomClasse::NomMethode).const placé après une
méthode indique qu’elle ne modifie pas l’objet. Cela améliore la
robustesse et la lisibilité.Une classe peut définir des constructeurs pour initialiser ses objets et un destructeur pour exécuter du code lors de leur destruction.
#include <iostream>
#include <cmath>
struct vec3 {
float x, y, z;
// Constructeur vide
vec3();
// Constructeur personnalisé
vec3(float v);
// Destructeur
~vec3();
};
// Initialisation à 0
vec3::vec3() : x(0.0f), y(0.0f), z(0.0f) { }
// Initialisation avec une valeur commune
vec3::vec3(float v) : x(v), y(v), z(v) { }
// Destructeur
vec3::~vec3() {
std::cout << "Goodbye vec3" << std::endl;
}
int main() {
vec3 a; // appelle vec3()
vec3 b(1.0f); // appelle vec3(float)
return 0; // appelle ~vec3()
}= default)Dans certains de cas, on ne souhaite pas redéfinir un constructeur ou
un destructeur, mais simplement demander explicitement au compilateur de
générer automatiquement l’implémentation par défaut. On
utilise alors la syntaxe = default.
struct vec3 {
float x, y, z;
// Génère automatiquement un constructeur par défaut
vec3() = default;
// Génère automatiquement un destructeur par défaut
~vec3() = default;
};Ceci est équivalent à ne rien écrire, mais a deux avantages :
Lisibilité : cela rend explicite qu’un constructeur ou un destructeur existe et doit être celui fourni par le compilateur.
Robustesse : permet d’éviter certaines suppressions implicites de constructeur/destructeur si d’autres sont définis dans la classe.
En C++, le choix entre une méthode (fonction membre) et une fonction externe est laissé au développeur. Par exemple, la norme peut aussi être définie comme une fonction indépendante :
#include <cmath>
struct vec3 {
float x, y, z;
};
// Norme comme fonction non-membre
float norm(const vec3& p) {
return std::sqrt(p.x*p.x + p.y*p.y + p.z*p.z);
}
int main() {
vec3 p = {1.0f, 2.0f, 3.0f};
float n = norm(p); // appel en tant que fonction
}L’utilisation de const& évite de copier inutilement
l’objet.
En C++, la bibliothèque <fstream>
permet d’écrire et de lire des données dans des fichiers. Celle-ci
fournit trois classes principales :
std::ifstream (input file
stream) : pour lire un fichier (entrée).std::ofstream (output file
stream) : pour écrire dans un fichier (sortie).std::fstream : pour combiner lecture
et écriture.On souhaite sauvegarder les coordonnées d’un vec3 dans
un fichier texte.
#include <iostream>
#include <fstream>
#include <cmath>
struct vec3 {
float x, y, z;
};
int main() {
vec3 p = {1.0f, 2.0f, 3.5f};
std::ofstream file("vec3.txt"); // ouverture en écriture
if (!file.is_open()) {
std::cerr << "Erreur : impossible d’ouvrir le fichier !" << std::endl;
return 1;
}
file << "Bonjour C++ !" << std::endl;
file << p.x << " " << p.y << " " << p.z << std::endl;
file.close(); // fermeture du fichier
return 0;
}Après exécution, le fichier vec3.txt contient :
Bonjour C++ !
1 2 3.5
On peut ensuite relire ce vec3 depuis le fichier :
#include <iostream>
#include <fstream>
#include <cmath>
struct vec3 {
float x, y, z;
};
int main() {
vec3 p;
std::ifstream file("vec3.txt"); // ouverture en lecture
if (!file) {
std::cerr << "Erreur : fichier introuvable !" << std::endl;
return 1;
}
std::string line;
std::getline(file, line);
file >> p.x >> p.y >> p.z; // lecture des trois valeurs
file.close();
std::cout << "vec3 relu : (" << p.x << ", " << p.y << ", " << p.z << ")" << std::endl;
return 0;
}Affichage attendu :
vec3 relu : (1, 2, 3.5)
Lors de l’ouverture d’un fichier, on peut préciser des modes :
std::ios::in : lecture (par défaut pour
ifstream).std::ios::out : écriture (par défaut pour
ofstream).std::ios::app : ajout à la fin du fichier sans
l’effacer.std::ios::binary : lecture/écriture en mode binaire
(ex. images).Exemple :
std::ofstream file("log.txt", std::ios::app); // ouverture en ajout
file << "Nouvelle entrée" << std::endl;Lorsqu’un programme devient volumineux, il est nécessaire de séparer le code en plusieurs fichiers afin de préserver la lisibilité, la modularité et de faciliter la maintenance.
Une organisation typique avec des classes en C++ repose sur trois types de fichiers :
Fichier d’en-tête (.hpp ou .h)
Fichier d’implémentation (.cpp)
.hpp.Fichier principal ou d’utilisation (main.cpp, etc.)
main() et utilise les
classes/fonctions en incluant le fichier d’en-tête.vec3vec3.hpp#pragma once
#include <cmath>
// Déclaration de la classe
struct vec3 {
float x, y, z;
float norm() const;
void normalize();
};
// Fonction non-membre
float dot(vec3 const& a, vec3 const& b);vec3.cpp#include "vec3.hpp"
// Méthodes de vec3
float vec3::norm() const {
return std::sqrt(x*x + y*y + z*z);
}
void vec3::normalize() {
float n = norm();
x /= n; y /= n; z /= n;
}
// Fonction non-membre
float dot(vec3 const& a, vec3 const& b) {
return a.x*b.x + a.y*b.y + a.z*b.z;
}main.cpp#include "vec3.hpp"
#include <iostream>
int main() {
vec3 v = {1.0f, 2.0f, 3.0f};
std::cout << "Norme : " << v.norm() << std::endl;
v.normalize();
std::cout << "Norme après normalisation : " << v.norm() << std::endl;
vec3 w = {2.0f, -1.0f, 0.0f};
std::cout << "Produit scalaire v.w = " << dot(v, w) << std::endl;
return 0;
}#include "vec3.hpp"
copie-colle le contenu du fichier .hpp au
moment de la compilation.vec3
doivent inclure son fichier d’en-tête (vec3.hpp)..cpp dans un
autre fichier.#pragma onceLa directive #pragma once est utilisée en en-tête pour
éviter les inclusions multiples d’un même fichier. Lorsqu’un fichier
.hpp est inclus plusieurs fois (directement ou
indirectement), cela peut provoquer des erreurs de compilation liées à
des redéfinitions de classes ou de fonctions.
Avec #pragma once, le compilateur garantit que le
contenu du fichier ne sera inclus qu’une seule fois, même si plusieurs
fichiers tentent de l’inclure.
C’est une alternative plus concise et lisible que les gardes d’inclusion
classiques utilisant #ifndef, #define et
#endif.
En pratique, il est recommandé d’ajouter systématiquement
#pragma once en tête de vos fichiers d’en-tête.
En C++, la compilation est le processus qui
transforme le code source lisible par un humain (fichiers
.cpp et .hpp) en un programme exécutable
compréhensible par l’ordinateur. Cette transformation s’effectue en
plusieurs étapes. Le compilateur commence par analyser le code et le
traduit en code assembleur.
Le code assembleur est un langage de bas niveau qui correspond directement aux instructions compréhensibles par le processeur. Contrairement au C++ qui est portable entre systèmes et processeurs, l’assembleur est dépendant de l’architecture matérielle (Intel x86, ARM, etc.). Chaque ligne de C++ peut ainsi donner lieu à une ou plusieurs instructions assembleur, telles que des opérations de calcul, de copie mémoire ou de saut conditionnel.
Ensuite, ce code assembleur est converti en code machine binaire qui constitue le langage natif du processeur. Ce code est stocké dans un fichier objet binaire. Finalement, un éditeur de liens (linker) assemble les différents fichiers objets et les bibliothèques utilisées pour produire l’exécutable final.
Ainsi, le rôle de la compilation est de traduire un langage de haut niveau (C++) en instructions de bas niveau (assembleur, puis machine) que le processeur peut exécuter directement, tout en optimisant les performances.
Fichier source (.cpp)
↓ (compilateur)
Fichier objet (.o)
↓ (linker / éditeur de liens)
Exécutable (programme binaire)
int add(int a, int b) {
return a + b;
}
int main() {
int x = add(2, 3);
return x;
}add(int, int): # Début de la fonction add
mov eax, edi # Copier le 1er argument (a) dans eax
add eax, esi # Ajouter le 2ème argument (b)
ret # Retourner eax (résultat)
main: # Début de la fonction main
push rbp # Sauvegarde du pointeur de base
mov edi, 2 # Charger 2 dans le registre edi (1er argument)
mov esi, 3 # Charger 3 dans le registre esi (2e argument)
call add(int, int) # Appeler la fonction add
pop rbp # Restaurer le pointeur de base
ret # Retourner le résultat dans eaxedi et esi : registres
utilisés pour passer les 1er et 2e arguments aux fonctions (convention
d’appel x86-64 System V).eax : registre où le résultat est
stocké et retourné par la fonction.mov : copie une valeur dans un
registre.add : effectue une addition entre deux
registres.ret : retourne de la fonction, en
utilisant la valeur présente dans eax comme résultat.Sur Linux et MacOS, les compilateurs les plus utilisés sont
g++ (GNU) et clang++ (LLVM).
Pour compiler un programme simple (un seul fichier) :
g++ main.cpp -o programmeou
clang++ main.cpp -o programmemain.cpp : fichier source C++ à compiler.-o programme : nom de l’exécutable produit.Si le projet contient plusieurs fichiers, il devient fastidieux de tout compiler à la main. On utilise alors un Makefile avec l’outil make, qui décrit les dépendances et les règles de compilation.
Exemple minimal de Makefile :
Voici ton Makefile annoté avec la syntaxe générale en commentaire :
# Cible par défaut (ici : "main")
all: main
# Syntaxe générale :
# cible: dépendances
# commande(s) à exécuter
# Construction de l'exécutable "main"
main: main.o vec3.o
g++ main.o vec3.o -o main
# Syntaxe générale :
# executable: fichiers_objets
# compilateur fichiers_objets -o executable
# Règle pour générer l'objet main.o
main.o: main.cpp vec3.hpp
g++ -c main.cpp
# Syntaxe générale :
# fichier.o: fichier.cpp fichiers_inclus.hpp
# compilateur -c fichier.cpp
# Règle pour générer l'objet vec3.o
vec3.o: vec3.cpp vec3.hpp
g++ -c vec3.cpp
# Syntaxe générale :
# fichier.o: fichier.cpp fichiers_inclus.hpp
# compilateur -c fichier.cpp
# Nettoyage des fichiers intermédiaires
clean:
rm -f *.o main
# Syntaxe générale :
# clean:
# commande pour supprimer les fichiers générés
Sur Windows, le compilateur est fourni directement par
Microsoft Visual Studio (MSVC). Il ne repose pas sur
make ni sur des Makefiles. Au lieu de cela, le code est
organisé dans un projet Visual Studio
(.sln) qui décrit les fichiers, dépendances et options de
compilation.
L’IDE Visual Studio se charge de lancer automatiquement le
compilateur MSVC lorsque vous appuyez sur “Build” ou “Run”. Ainsi, il
n’est pas nécessaire (et pas pratique) d’appeler manuellement
cl.exe en ligne de commande.
Pour éviter d’écrire un Makefile spécifique à Linux et un projet Visual Studio spécifique à Windows, on utilise CMake.
CMake est un outil de génération de projet.
Il lit un fichier de configuration (CMakeLists.txt)
et génère automatiquement les fichiers adaptés à votre système :
make..sln).Exemple d’utilisation sous Linux/MacOS:
# Depuis le répertoire du projet
mkdir build
cd build
cmake ..
make # sous Linux/MacOSg++ ou
clang++, automatisation via Makefile..sln).En C++, les variables sont typées : chaque variable correspond à un espace mémoire (une ou plusieurs cases) interprété selon un type. Exemples de types fondamentaux :
int a = 5; // entier signé (typiquement 4 octets)
float b = 5.0f; // flottant simple précision (4 octets)
double c = 5.0; // flottant double précision (8 octets)
char d = 'k'; // caractère (1 octet = 8 bits), équivaut à 107 en ASCII
size_t e = 100; // entier non signé permettant d'encoder une position en mémoire (8 octets sur machines 64 bits), il est utilisé pour indiquer les tailles de tableaux ex. size() d'un std::vector.À noter :
char garanti sur 1 octet).À la base, la mémoire d’un ordinateur ne stocke que des
0 et des 1 : c’est le système
binaire (base 2). Chaque position s’appelle un
bit (binary digit). Les bits sont regroupés par paquets
de 8, formant un octet (byte en anglais). Un octet est
la plus petite unité adressable en mémoire.
Pour représenter un entier en binaire, on utilise les puissances de
2, de la même manière qu’en base 10 on utilise les puissances de 10. Par
exemple, le nombre décimal 156 s’écrit
10011100 en binaire, car :
\[ 1 \times 2^7 + 0 \times 2^6 + 0 \times 2^5 + 1 \times 2^4 + 1 \times 2^3 + 1 \times 2^2 + 0 \times 2^1 + 0 \times 2^0 = 156 \]
Voici quelques correspondances entre décimal et binaire sur 8 bits :
| Décimal | Binaire (8 bits) |
|---|---|
| 0 | 00000000 |
| 1 | 00000001 |
| 2 | 00000010 |
| 3 | 00000011 |
| 4 | 00000100 |
| 156 | 10011100 |
En pratique, un entier occupe souvent plusieurs
octets en mémoire. Le type int en C++ utilise
typiquement 4 octets (32 bits), ce qui permet de représenter \(2^{32}\) valeurs distinctes. Le type
long long utilise 8 octets (64 bits), soit \(2^{64}\) valeurs possibles.
Lorsqu’un entier est non signé
(unsigned), tous les bits servent à représenter la valeur :
il n’y a pas de notion de signe. Un unsigned int sur 4
octets (32 bits) peut donc coder des valeurs de 0 à \(2^{32} - 1 = 4\,294\,967\,295\).
En programmation, il est courant d’utiliser la notation
hexadécimale (base 16) pour représenter les octets de
manière plus compacte. Chaque chiffre hexadécimal (0-9,
A-F) représente exactement 4 bits, donc un octet s’écrit
avec exactement 2 caractères hexadécimaux. Par exemple :
10011100 en binaire = 9C en hexadécimal =
156 en décimal00000000 00000000 00000000 00000000 =
00000000 en hexa → valeur 011111111 11111111 11111111 11111111 =
FFFFFFFF en hexa → valeur 4294967295Pour représenter des nombres négatifs, les entiers signés
(int, short, etc.) utilisent une convention
appelée le complément à deux. Le bit le plus à gauche
(MSB, most significant bit) indique le signe :
0 pour positif, 1 pour négatif.
L’intérêt du complément à deux, par rapport à un simple bit de signe, est que l’addition fonctionne de la même manière pour les nombres positifs et négatifs, sans circuit supplémentaire. Pour obtenir la représentation d’un nombre négatif :
0
deviennent 1 et inversement).Exemple sur 8 bits pour obtenir -5 :
00000101 = +5
Inverse → 11111010
Ajout +1 → 11111011 = -5
On peut vérifier : 11111011 + 00000101 = 100000000. Le
1 en trop déborde sur 9 bits et est ignoré, donnant bien
00000000 = 0. C’est cette propriété qui rend le complément
à deux si pratique pour le matériel.
Conséquence sur les plages de valeurs :
-128 à +127 (le zéro prend
une place côté positif).int) : de \(-2\,147\,483\,648\) à \(+2\,147\,483\,647\).Prenons l’entier encodé sur 2 octets dont la représentation
hexadécimale est C4 8D :
C4 8D (hexadécimal)
= 11000100 10001101 (binaire)
La même séquence de bits peut être interprétée de deux façons selon le type :
En non signé (unsigned short) : on
calcule directement la valeur en base 2, ce qui donne
50317.
En signé (short, complément à deux)
: le MSB est 1, donc le nombre est négatif. On applique la
procédure inverse :
00111011 0111001000111011 01110011 =
15219-15219.Cet exemple illustre un point fondamental : les bits en mémoire n’ont pas de sens intrinsèque. C’est le type de la variable qui détermine comment ils sont interprétés.
Les flottants (float, double) suivent la
norme IEEE 754.
Un nombre flottant est représenté par trois parties :
float, 11 bits
pour double)float, 52 bits
pour double)Formule :
\[ x = (-1)^s \times (1 + mantisse) \times 2^{exposant - biais} \]
float (32 bits) → biais = 127double (64 bits) → biais = 1023Exemple : 46 3F CC 30 (float en hexadécimal) =
12275.046875 en décimal.
Propriétés à connaître :
0.1, 0.4).Du fait de la représentation en virgule flottante, des résultats qui devraient être identiques en mathématiques ne le sont pas en pratique. Voici des erreurs classiques à éviter :
// Ne JAMAIS comparer des flottants avec ==
if (a == 0.3) { ... } // FAUX : risque d'erreur d'arrondi
// 0.1 + 0.2 n'est PAS égal à 0.3
double x = 0.1 + 0.2;
if (x == 0.3) { ... } // FAUX ! (x vaut 0.30000000000000004...)
// Les erreurs s'accumulent dans les boucles
double sum = 0.0;
for (int i = 0; i < 1000; i++)
sum += 0.1;
// sum != 100.0 (la valeur réelle sera légèrement différente)Bonne pratique : tolérance absolue. On compare la distance entre deux valeurs avec un seuil ε :
const double eps = 1e-9;
if (std::abs(a - b) < eps) { ... } // OKTolérance relative pour les grands nombres. Quand les valeurs manipulées sont grandes, un ε absolu fixe peut être trop petit. On utilise alors une tolérance proportionnelle à la grandeur des valeurs :
bool approx_equal(double a, double b, double eps = 1e-9) {
return std::abs(a - b) <= eps * std::max(std::abs(a), std::abs(b));
}Cette fonction adapte le seuil de tolérance à l’ordre de grandeur des nombres comparés.
Quand un entier occupe plusieurs octets (par exemple un
int de 4 octets), l’ordinateur doit décider dans
quel ordre les octets sont stockés en mémoire. C’est ce qu’on
appelle l’endianness (ou ordre des octets).
Big Endian (certaines architectures réseau, PowerPC, anciens processeurs)
L’octet de poids fort (most significant byte) est stocké en premier (à l’adresse la plus petite).
C’est l’ordre qui semble le plus naturel : on écrit le nombre “de gauche à droite”, comme en décimal.
Pour la valeur 0x12345678 :
Adresse : 1000 1001 1002 1003
Contenu : 12 34 56 78Little Endian (Intel x86, ARM en mode par défaut)
L’octet de poids faible (least significant byte) est stocké en premier.
Pour la même valeur 0x12345678 :
Adresse : 1000 1001 1002 1003
Contenu : 78 56 34 12Le Big Endian paraît plus intuitif, mais le Little Endian s’est imposé historiquement avec les processeurs Intel x86, puis ARM a suivi. À l’époque des premiers microprocesseurs 8 bits, le Little Endian simplifiait certains circuits (l’adresse d’une valeur restait la même quelle que soit sa taille, et les opérations arithmétiques pouvaient démarrer par l’octet de poids faible lu en premier).
Compatibilité réseau : les protocoles (TCP/IP, etc.) imposent le Big Endian (network byte order). Les PC classiques (Intel) utilisent le Little Endian : il faut donc convertir avant d’envoyer ou après réception.
Fichiers binaires : si un programme écrit un fichier binaire en Little Endian, il doit préciser cet ordre. Sinon, sur une machine Big Endian, les valeurs lues seront fausses.
Interopérabilité : toute communication entre machines hétérogènes doit expliciter l’ordre des octets.
| Type | Description | Taille typique (x86/64 bits) | Exemple de déclaration |
|---|---|---|---|
char |
caractère ASCII (ou petit entier signé) | 1 octet | char c = 'A'; |
bool |
valeur booléenne (true ou false) |
1 octet (optimisé en vector) | bool b = true; |
short |
entier court signé | 2 octets | short s = 123; |
int |
entier signé standard | 4 octets | int a = 42; |
long |
entier signé (taille variable selon archi) | 4 octets (Windows), 8 (Linux) | long l = 100000; |
long long |
entier long signé (garanti >= 64 bits) | 8 octets | long long x = 1000000000000LL; |
unsigned |
entier non signé (≥0 uniquement) | même taille que signé | unsigned u = 42; |
float |
nombre flottant simple précision (IEEE754) | 4 octets | float f = 3.14f; |
double |
nombre flottant double précision | 8 octets | double d = 2.718; |
long double |
flottant précision étendue (dépend archi) | 8, 12 ou 16 octets | long double pi = 3.14159; |
size_t |
entier non signé pour l’adressage mémoire | 8 octets (64 bits) | size_t n = vec.size(); |
wchar_t |
caractère large (Unicode, dépend plateforme) | 2 octets (Windows), 4 (Linux) | wchar_t wc = 'é'; |
Attention: La taille peut varier selon le compilateur et
l’architecture, sauf char qui fait toujours 1
octet.
sizeofEn C et C++, l’opérateur sizeof retourne la taille en
octets d’un type ou d’une variable.
Exemples :
#include <stdio.h>
int main() {
printf("sizeof(char) = %zu\n", sizeof(char));
printf("sizeof(int) = %zu\n", sizeof(int));
printf("sizeof(float) = %zu\n", sizeof(float));
printf("sizeof(double)= %zu\n", sizeof(double));
int a;
double b;
printf("sizeof(a) = %zu\n", sizeof(a));
printf("sizeof(b) = %zu\n", sizeof(b));
return 0;
}Sortie typique sur une machine 64 bits :
sizeof(char) = 1
sizeof(int) = 4
sizeof(float) = 4
sizeof(double)= 8
sizeof(a) = 4
sizeof(b) = 8
Rem. : le spécificateur %zu est celui prévu par la norme
pour afficher une valeur de type size_t (par ex. le
résultat de sizeof). Il est également possible de convertir
vers unsigned long et utiliser %lu.
Points à retenir
sizeof(type) est évalué à la
compilation, sans exécuter le programme.struct.sizeof.Pour obtenir des tailles déterministes (indépendantes de
l’architecture), le standard C/C++ définit les types dans l’en-tête
<cstdint> (C++11 / C99). Ces types garantissent un
nombre de bits précis, ce qui est essentiel pour la sérialisation, les
formats binaires et les protocoles réseau.
Principaux types fixes :
uint8_t / int8_t : entier non signé /
signé sur 8 bitsuint16_t / int16_t : entier non signé /
signé sur 16 bitsuint32_t / int32_t : entier non signé /
signé sur 32 bitsuint64_t / int64_t : entier non signé /
signé sur 64 bitsExemples utiles complémentaires :
int_fast32_t, uint_fast32_t : types
entiers au moins de 32 bits mais choisis pour de meilleures performances
sur la plateformeint_least16_t, uint_least16_t : types
entiers d’au moins 16 bits (garantie minimale)intptr_t, uintptr_t : entiers signés/non
signés capables de contenir une valeur de pointeurExemple d’utilisation :
#include <cstdint>
#include <cinttypes> // pour les macros PRIu32, PRId64, ...
#include <cstdio>
int main() {
uint8_t a = 255;
int16_t b = -12345;
uint32_t c = 0xDEADBEEF;
std::printf("sizeof(uint8_t) = %zu\n", sizeof(uint8_t));
std::printf("sizeof(int16_t) = %zu\n", sizeof(int16_t));
std::printf("sizeof(uint32_t) = %zu\n", sizeof(uint32_t));
// utilisation sûre avec printf :
std::printf("c = %" PRIu32 "\n", c);
return 0;
}Les opérations bit à bit (bitwise) permettent de manipuler directement les bits d’un entier. Elles sont très utiles pour travailler sur des flags, des masques, optimiser des calculs simples, ou pour le traitement bas-niveau de données (compression, formats binaires, etc.).
Principales opérations en C/C++ :
& : ET bit à bit| : OU bit à bit^ : XOR (OU exclusif) bit à bit~ : NOT (négation) bit à bit<< : décalage à gauche (shift left)>> : décalage à droite (shift right)Exemples simples :
unsigned a = 0b1100; // la notation 0bxxxx permet de définir une valeur en binaire, ici 1100 en binaire => 12 en base décimale.
unsigned b = 0b1010; // 1010 en binaire => 10 en décimale
unsigned and_ab = a & b; // 1000 (8)
unsigned or_ab = a | b; // 1110 (14)
unsigned xor_ab = a ^ b; // 0110 (6)
unsigned not_a = ~a; // inversion de tous les bits
// décalements
unsigned left = a << 1; // 11000 (24) : décalage vers la gauche (multiplication par 2)
unsigned right = a >> 2; // 0011 (3) : décalage vers la droite (division par 4)
// affichez en hex / décimale selon besoinMasques et tests de bits
On utilise des masques pour isoler, définir ou effacer des bits :
unsigned flags = 0;
const unsigned FLAG_A = 1u << 0; // bit 0 -> 0b0001
const unsigned FLAG_B = 1u << 1; // bit 1 -> 0b0010
const unsigned FLAG_C = 1u << 2; // bit 2 -> 0b0100
// activer un flag
flags |= FLAG_B; // flags = 0b0010
// tester si un flag est activé
bool hasB = (flags & FLAG_B) != 0;
// désactiver un flag
flags &= ~FLAG_B; // efface le bit 1
// basculer (toggle) un flag
flags ^= FLAG_C; // inverse l'état du bit 2Conseils importants
unsigned,
uint32_t, uint64_t) pour les opérations bit à
bit : le comportement des décalages sur des entiers signés négatifs peut
être indéfini ou dépendre de l’implémentation.x << n multiplie par
2^n lorsque cela ne provoque pas de débordement. Le
décalage à droite x >> n divise par 2^n
pour les types non signés.uint32_t w = 0x12345678;
uint8_t byte0 = (w >> 0) & 0xFF; // 0x78 (LSB)
uint8_t byte1 = (w >> 8) & 0xFF; // 0x56
uint8_t byte2 = (w >> 16) & 0xFF; // 0x34
uint8_t byte3 = (w >> 24) & 0xFF; // 0x12 (MSB)Utiliser std::bitset pour afficher/manipuler des bits de
façon sûre et lisible :
#include <bitset>
#include <iostream>
std::bitset<8> bs(0b10110010);
std::cout << bs << "\n"; // affiche 10110010
bs.flip(0); // bascule le bit 0
bs.set(3); // met à 1 le bit 3
bs.reset(7);// met à 0 le bit 7Pour comprendre les pointeurs, il faut d’abord comprendre comment les
variables sont réellement stockées dans la machine. Lorsqu’on écrit
int a = 42; en C++, cette valeur n’existe pas “dans
l’abstrait” : elle est physiquement inscrite quelque part dans la
mémoire vive (RAM) de l’ordinateur.
La mémoire peut être vue comme un grand tableau linéaire de cases, où chaque case contient exactement un octet (8 bits). Chaque case possède une adresse unique — un nombre entier qui permet au processeur de la localiser. On peut imaginer la mémoire comme un long ruban de cases numérotées :
Adresse Contenu (binaire)
1000 10101010
1001 00001111
1002 11110000
1003 01010101
...
Quand on déclare une variable, le compilateur lui attribue une zone
de cases consécutives en mémoire. La taille de cette zone dépend du type
: un char occupe 1 octet, un int en occupe
généralement 4, un double en occupe 8. Par exemple, si l’on
déclare :
int b = 12;
char c = 'a';
short d = 8;Le compilateur place ces trois variables à différents endroits en mémoire. Elles ne sont pas forcément côte à côte : d’autres variables, du padding ou des zones inutilisées peuvent s’intercaler. L’important est que chaque variable occupe un bloc contigu d’octets, et que le compilateur (et le programmeur, via les pointeurs) puisse retrouver ce bloc grâce à l’adresse de son premier octet.
Sur cette figure, on voit que c (1 octet, en vert) se
trouve à l’adresse 1002, b (4 octets, en orange) commence à
l’adresse 1003, et d (2 octets, en bleu) se trouve plus
loin à l’adresse 1009. Les cases grises entre les variables sont des
zones non utilisées par ces variables — elles peuvent contenir d’autres
données ou du padding.
Par souci de performance, le compilateur peut introduire du padding (des octets de remplissage) pour que certaines variables commencent à des adresses multiples de 2, 4 ou 8. Ce mécanisme d’alignement permet au processeur d’accéder aux données plus efficacement, car les bus mémoire sont optimisés pour lire des blocs alignés.
Chaque variable en mémoire possède une adresse,
c’est-à-dire la position de son premier octet dans le grand tableau de
la mémoire. En langage C (et donc aussi en C++), on peut accéder à cette
adresse grâce à l’opérateur & (dit
adresse de).
#include <stdio.h>
int main() {
int a = 42;
printf("Valeur de a : %d\n", a);
printf("Adresse de a : %p\n", &a);
return 0;
}Sortie possible (l’adresse dépend de l’exécution et de la machine) :
Valeur de a : 42
Adresse de a : 0x7ffee3b5a9c
%d affiche la valeur entière (42
ici).%p affiche une adresse mémoire (format pointeur).&a signifie “l’adresse de la variable
a”.scanfQuand on utilise scanf, on doit fournir
l’adresse de la variable dans laquelle stocker le
résultat.
#include <stdio.h>
int main() {
int age;
printf("Entrez votre age : ");
scanf("%d", &age); // &age = adresse de age
printf("Vous avez %d ans.\n", age);
return 0;
}scanf("%d", &age) place la valeur lue
directement dans la case mémoire de age.scanf("%d", age) (sans
&), le programme plantera, car scanf a
besoin de l’adresse pour modifier la variable.On peut constater que deux variables successives en mémoire ont des adresses différentes, séparées par leur taille en octets.
#include <stdio.h>
int main() {
int x = 10;
int y = 20;
printf("Adresse de x : %p\n", &x);
printf("Adresse de y : %p\n", &y);
return 0;
}Exemple de sortie :
Adresse de x : 0x7ffee3b5a98
Adresse de y : 0x7ffee3b5a94
Remarque: Les adresses sont proches mais pas forcément dans l’ordre croissant, car le compilateur et le système peuvent organiser les variables différemment (pile, alignement mémoire, etc.).
Un pointeur est une variable qui contient une adresse mémoire. Cependant, si un pointeur n’est pas initialisé, il peut contenir une adresse aléatoire, ce qui conduit à des comportements imprévisibles (segmentation fault, corruption mémoire).
Règle essentielle : toujours initialiser les pointeurs.
En C++ moderne, on utilise nullptr pour indiquer qu’un
pointeur ne pointe vers rien :
#include <iostream>
int main() {
int* p = nullptr; // pointeur initialisé, mais ne pointe vers rien
if(p == nullptr) {
std::cout << "Le pointeur est vide, pas d'accès dangereux." << std::endl;
}
return 0;
}int* p; // pointeur non initialisé (dangereux !)
*p = 10; // comportement indéfini → crash probableIci, p contient une valeur indéterminée : accéder à
*p est dangereux.
int* p = nullptr; // pointeur sûr, mais vide
if(p != nullptr) {
*p = 10; // on accède uniquement si p pointe vers une variable valide
}En C et C++, les arguments des fonctions sont passés par valeur :
Exemple :
#include <stdio.h>
void increment(int x) {
x = x + 1; // modifie uniquement la copie locale
}
int main() {
int a = 5;
increment(a);
printf("a = %d\n", a); // affiche toujours 5
return 0;
}Explication mémoire :
a dans main occupe une zone mémoire.increment(a), la valeur 5
est copiée dans une nouvelle variable x locale à la
fonction.x ne change pas a, car ce sont
deux variables indépendantes.Si on veut qu’une fonction puisse modifier la variable originale, il faut lui transmettre non pas la valeur, mais l’adresse de la variable.
Exemple :
#include <stdio.h>
void increment(int* p) {
*p = *p + 1; // modifie la valeur à l'adresse pointée
}
int main() {
int a = 5;
increment(&a); // on passe l'adresse de a
printf("a = %d\n", a); // affiche 6
return 0;
}Explication détaillée :
Dans main, on a la variable a (valeur
5) stockée à une certaine adresse mémoire (par ex. 1000).
L’expression &a produit cette adresse
(1000).
Lors de l’appel increment(&a), ce n’est
pas a qui est copié, mais son
adresse (1000).
p,
qui est une copie de l’adresse.À l’intérieur de increment, *p signifie
« la valeur contenue à l’adresse p ».
*p = *p + 1; va chercher la valeur 5
à l’adresse 1000, l’incrémente, et stocke 6 à la même
place.Comme p désigne la mémoire de a, la
variable a est réellement modifiée.
En résumé :
*p.Les tableaux sont un cas fondamental pour comprendre le lien entre pointeurs et mémoire. Contrairement aux variables individuelles qui peuvent être dispersées en mémoire (comme on l’a vu dans la section précédente), les éléments d’un tableau sont toujours stockés côte à côte, sans aucun espace entre eux. Cette propriété, appelée contiguïté mémoire, est ce qui rend les tableaux très efficaces : le processeur peut accéder à n’importe quel élément en calculant directement son adresse à partir de l’adresse du premier élément et de l’indice.
En C et C++, un tableau est toujours stocké en
mémoire comme une suite contiguë d’octets. Si un
tableau de 3 int commence à l’adresse 0x1000,
le premier élément occupe les octets 0x1000 à
0x1003, le deuxième 0x1004 à
0x1007, et le troisième 0x1008 à
0x100B. Il n’y a jamais de “trou” entre les éléments.
Exemple :
#include <stdio.h>
int main() {
int tab[3] = {10, 20, 30};
printf("Adresse de tab[0] : %p\n", &tab[0]);
printf("Adresse de tab[1] : %p\n", &tab[1]);
printf("Adresse de tab[2] : %p\n", &tab[2]);
return 0;
}Sortie possible :
Adresse de tab[0] : 0x7ffee6c4a90
Adresse de tab[1] : 0x7ffee6c4a94
Adresse de tab[2] : 0x7ffee6c4a98
On remarque que les adresses sont espacées de 4 octets (la taille
d’un int), ce qui confirme la contiguïté
mémoire.
C’est la contiguïté mémoire qui rend possible l’arithmétique
des pointeurs, une des mécaniques centrales du C et du C++. Le
nom d’un tableau (tab) est automatiquement converti en
pointeur vers son premier élément
(&tab[0]). À partir de ce pointeur, on peut naviguer
vers n’importe quel élément par simple calcul d’adresse :
p + N ne signifie pas “ajouter N octets”, mais “avancer
de N éléments”. Le compilateur multiplie
automatiquement N par sizeof(type) pour obtenir le décalage
en octets.*(p + N) déréférence le pointeur décalé, ce qui donne
la valeur du N-ième élément.Ainsi, tab[N] et *(tab + N) sont
strictement équivalents — c’est d’ailleurs ainsi que le compilateur
implémente l’opérateur [] en interne.
Exemple :
#include <stdio.h>
int main() {
int tab[3] = {10, 20, 30};
int* p = tab; // équivaut à &tab[0]
printf("%d\n", *(p + 0)); // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 2)); // 30
return 0;
}Ces deux écritures sont équivalentes :
tab[i] <=> *(tab + i)
Adresse : 1000 1004 1008
Contenu : 10 20 30
Indice : tab[0] tab[1] tab[2]
p = 1000
*(p+0) → valeur à 1000 → 10
*(p+1) → valeur à 1004 → 20
*(p+2) → valeur à 1008 → 30
La contiguïté mémoire s’applique à tout type de tableau, pas
seulement aux entiers. Si on définit un tableau d’objets plus volumineux
(par exemple des double ou des struct), les
éléments restent stockés les uns à la suite des autres.
double#include <stdio.h>
int main() {
double tab[3] = {1.1, 2.2, 3.3};
printf("Adresse de tab[0] : %p\n", &tab[0]);
printf("Adresse de tab[1] : %p\n", &tab[1]);
printf("Adresse de tab[2] : %p\n", &tab[2]);
return 0;
}Sortie possible (chaque double = 8 octets) :
Adresse de tab[0] : 0x7ffee6c4a90
Adresse de tab[1] : 0x7ffee6c4a98
Adresse de tab[2] : 0x7ffee6c4aa0
On voit que les adresses sont espacées de 8, car un
double occupe 8 octets.
std::vectorEn C++ moderne, on utilise std::vector plutôt que des
tableaux statiques, car il offre :
push_back),Exemple :
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {10, 20, 30};
std::cout << "Adresse de v[0] : " << &v[0] << std::endl;
std::cout << "Adresse de v[1] : " << &v[1] << std::endl;
std::cout << "Adresse de v[2] : " << &v[2] << std::endl;
}Sortie typique :
Adresse de v[0] : 0x7ffee6c4a90
Adresse de v[1] : 0x7ffee6c4a94
Adresse de v[2] : 0x7ffee6c4a98
On observe la même contiguïté qu’avec les tableaux classiques.
std::vectorOn peut récupérer un pointeur sur les données internes grâce à
v.data() ou &v[0], puis utiliser la même
logique que pour les tableaux C.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {10, 20, 30};
int* p = v.data(); // pointeur vers le premier élément
std::cout << *(p+0) << std::endl; // 10
std::cout << *(p+1) << std::endl; // 20
std::cout << *(p+2) << std::endl; // 30
}Synthèse :
std::vector stockent leurs
éléments de façon contiguë.tab[i]) ou via
l’arithmétique des pointeurs (*(p+i)).std::vector offrent en plus une taille
dynamique et une gestion sûre de la mémoire, mais conservent
les mêmes propriétés fondamentales de contiguïté.En C et C++, les structures (struct) et
classes regroupent plusieurs variables (membres) dans
un seul bloc mémoire. Par défaut, les champs sont rangés les uns à la
suite des autres, ce qui garantit une contiguïté
mémoire.
#include <stdio.h>
struct Point2D {
int x;
int y;
};
int main() {
struct Point2D p = {1, 2};
printf("Adresse de p.x : %p\n", &p.x);
printf("Adresse de p.y : %p\n", &p.y);
return 0;
}Sortie possible :
Adresse de p.x : 0x7ffee3b5a90
Adresse de p.y : 0x7ffee3b5a94
Ici, les deux entiers x et y (4 octets
chacun) sont stockés l’un après l’autre de manière contiguë.
Pour des raisons de performance, le compilateur peut insérer des octets de padding entre les membres afin de respecter un alignement mémoire optimal.
Exemple :
struct Test {
char a; // 1 octet
int b; // 4 octets
};Organisation en mémoire :
Adresse Contenu
1000 a (1 octet)
1001-1003 padding (3 octets inutilisés)
1004-1007 b (4 octets)
struct Mixed {
char c; // 1 octet
double d; // 8 octets
int i; // 4 octets
};Disposition typique sur une machine 64 bits :
Adresse Champ
1000 c (1 octet)
1001-1007 padding (7 octets)
1008-1015 d (8 octets)
1016-1019 i (4 octets)
1020-1023 padding (4 octets pour alignement global)
Taille totale : 24 octets.
En C++, une class se comporte comme une
struct du point de vue mémoire :
struct et class est
uniquement dans la visibilité par défaut (public vs
private).std::vector de
structuresEn C++ moderne, on peut stocker plusieurs objets struct
ou class dans un std::vector.
Le vector garantit que les éléments sont placés
contigus en mémoire, exactement comme pour un tableau
C.
Exemple :
#include <iostream>
#include <vector>
struct Point2D {
int x;
int y;
};
int main() {
std::vector<Point2D> points = {{1,2}, {3,4}, {5,6}};
std::cout << "Adresse du premier Point2D : " << &points[0] << std::endl;
std::cout << "Adresse du deuxième Point2D : " << &points[1] << std::endl;
std::cout << "Adresse du troisième Point2D : " << &points[2] << std::endl;
}std::vector<Point2D>Chaque Point2D occupe sizeof(Point2D)
octets (ici, 8 octets : 2 entiers de 4 octets). Les éléments du
std::vector sont rangés dos à dos en
mémoire :
Mémoire d'un std::vector<Point2D> avec 3 éléments
Adresse : 2000 2008 2016
Contenu : [x=1, y=2] [x=3, y=4] [x=5, y=6]
Taille : 8 octets 8 octets 8 octets
On voit que chaque élément est un bloc structuré, mais que les blocs restent contigus.
Résumé :
struct ou class sont
stockés contigus, avec du padding éventuel pour
respecter l’alignement.std::vector<struct> permet de
créer un tableau dynamique de structures également contigu en
mémoire.points.data().Lorsque l’on manipule des données structurées en grande quantité (par exemple des coordonnées 3D, des particules, des sommets en graphique), il existe deux façons classiques d’organiser les données en mémoire :
C’est la représentation classique avec un
std::vector<struct>. Chaque élément
du tableau est une structure complète.
Exemple :
struct Point3D {
float x, y, z;
};
std::vector<Point3D> points = {
{1.0f, 2.0f, 3.0f},
{4.0f, 5.0f, 6.0f},
{7.0f, 8.0f, 9.0f}
};Mémoire (chaque Point3D = bloc contigu de 12 octets)
:
[x=1, y=2, z=3] [x=4, y=5, z=6] [x=7, y=8, z=9]
Ici, la contiguïté s’applique au niveau des structures :
Point3D sont rangés dos à dos.Point3D lui-même contient ses champs
x, y, z contigus.Avantage : pratique pour manipuler un point complet.
Inconvénient : si l’on ne veut traiter que les
x, il faut parcourir inutilement les y et
z.
Ici, on inverse l’organisation : au lieu de stocker un tableau de structures, on stocke une structure qui contient un tableau par champ.
Exemple :
struct PointsSoA {
std::vector<float> x;
std::vector<float> y;
std::vector<float> z;
};Mémoire (chaque champ est contigu séparément) :
x : [1, 4, 7]
y : [2, 5, 8]
z : [3, 6, 9]
Ici, la contiguïté s’applique au niveau des champs :
x sont stockés les uns à la suite des
autres.y sont contigus, et de même pour les
z.Avantage : très efficace si l’on fait un traitement
massif sur un seul champ (ex. appliquer une transformation sur toutes
les coordonnées x). Inconvénient : moins
naturel si l’on veut travailler sur un point complet (x,y,z
regroupés).
{x,y,z}), et les blocs se
suivent.Les deux approches utilisent donc la contiguïté mémoire, mais pas au même niveau de structuration.
Jusqu’ici, nous avons vu des variables automatiques (déclarées dans une fonction), stockées sur la pile (stack) et détruites automatiquement à la fin du bloc.
Mais dans certains cas, on a besoin de données dont la durée de vie dépasse la fin d’un bloc (par exemple : conserver un tableau créé dans une fonction, gérer de grandes structures, ou construire des graphes dynamiques). Dans ce cas, on utilise la mémoire dynamique, allouée sur le tas (heap).
| Caractéristique | Pile (stack) | Tas (heap) |
|---|---|---|
| Allocation | Automatique | Manuelle (ou contrôlée par objets) |
| Durée de vie | Limitée au bloc courant | Jusqu’à libération explicite |
| Taille maximale | Limitée (quelques Mo) | Très grande (plusieurs Go) |
| Gestion | Par le compilateur | Par le programmeur |
| Exemple | int a; ou int tab[10]; |
new int; ou new int[n]; |
Sur la plupart des systèmes, la pile a une taille limitée (~8 Mo par défaut), alors que le tas peut utiliser plusieurs gigaoctets. L’allocation dynamique permet donc de créer des structures volumineuses ou de tailles variables à l’exécution.
#include <iostream>
int* createValue() {
int a = 42; // variable locale sur la pile
return &a; // Dangereux : a est détruit à la fin de la fonction
}
int main() {
int* p = createValue();
std::cout << *p << std::endl; // comportement indéfini !
}a est détruit à la sortie de createValue().
Le pointeur retourné devient dangling (dangereux).
#include <iostream>
int* createValue() {
int* p = new int(42); // alloué sur le tas
return p; // valide même après la fin de la fonction
}
int main() {
int* q = createValue();
std::cout << *q << std::endl; // 42
delete q; // libération obligatoire
}Ici, la variable *q persiste après la fin de
createValue(). Mais le programmeur doit libérer la
mémoire avec delete.
malloc et freeEn C, on utilise les fonctions de la bibliothèque standard
<stdlib.h>.
#include <stdlib.h>
int* p = (int*)malloc(sizeof(int));Ici :
malloc réserve un bloc de mémoire de
sizeof(int) octets,void*,int*.Utilisation :
#include <stdio.h>
#include <stdlib.h>
int main() {
int* p = (int*)malloc(sizeof(int));
if (p == NULL) {
return 1; // échec de l'allocation
}
*p = 42;
printf("%d\n", *p);
free(p); // libération
return 0;
}Points importants :
malloc n’initialise pas la
mémoire,free doit être appelé exactement une
fois pour chaque allocation réussie.int* tab = (int*)malloc(10 * sizeof(int));Accès :
tab[0] = 1;
tab[1] = 2;Libération :
free(tab);new et deleteEn C++, on dispose des opérateurs new et
delete, qui sont conscients des types et
appellent les constructeurs et destructeurs.
Allocation d’un objet :
int* p = new int(42);Libération :
delete p;Pour un tableau :
int* tab = new int[10];Libération correspondante :
delete[] tab;Règle fondamentale :
new ↔︎ deletenew[] ↔︎ delete[]Les mélanger conduit à un comportement indéfini.
struct Point {
float x, y;
Point(float a, float b) : x(a), y(b) {}
};
int main() {
Point* p = new Point(1.0f, 2.0f); // constructeur appelé
delete p; // destructeur appelé
}#include <iostream>
int* createArray(int n) {
int* arr = new int[n]; // allocation de n entiers
for(int i=0; i<n; ++i)
arr[i] = i * 10;
return arr;
}
int main() {
int n = 5;
int* arr = createArray(n);
for(int i=0; i<n; ++i)
std::cout << arr[i] << " ";
delete[] arr; // libération obligatoire
}Utilité : n est connu uniquement à
l’exécution → impossible d’utiliser un tableau statique.
Pile (stack) Tas (heap)
------------ ------------
int main() { new int[3]
int n = 3; ---------------
int* arr = new int[n]; --> | 0 | 1 | 2 | ...
---------------
}
n,
arr).delete[] arr; obligatoire.La gestion manuelle de la mémoire avec new et
delete est une source majeure de bugs en C++. Contrairement
à des langages comme Java ou Python qui disposent d’un garbage
collector (ramasse-miettes) libérant automatiquement la mémoire
inutilisée, en C++ c’est le programmeur qui est
responsable de libérer chaque allocation. Trois catégories de bugs
reviennent systématiquement :
Fuite mémoire (memory leak) : on alloue de la mémoire mais on oublie de la libérer. L’espace reste réservé pour rien, et si la fonction est appelée en boucle, la consommation mémoire croît jusqu’à épuiser les ressources du système.
void f() {
int* p = new int(10);
// oubli de delete → fuite mémoire
}Le problème est particulièrement insidieux car le programme continue de fonctionner — il ne plante pas immédiatement, il ralentit progressivement.
Double libération (double free) : on
appelle delete deux fois sur le même pointeur. La deuxième
fois, la mémoire a déjà été rendue au système, et tenter de la libérer à
nouveau corrompt les structures internes de l’allocateur.
int* p = new int(5);
delete p;
delete p; // erreur : libération doubleCela provoque un comportement indéfini : le programme peut planter immédiatement, corrompre silencieusement d’autres données, ou sembler fonctionner correctement avant de planter bien plus tard.
Utilisation après libération (use after free / dangling pointer) : on accède à la mémoire via un pointeur après l’avoir libérée. Le pointeur pointe toujours vers la même adresse, mais celle-ci peut avoir été réattribuée à un autre usage.
int* p = new int(5);
delete p;
std::cout << *p; // comportement indéfiniCe bug est difficile à détecter car il peut fonctionner “par chance” dans certaines exécutions et planter dans d’autres.
nullptr après libérationUne technique simple pour limiter les dangling pointers est
de mettre le pointeur à nullptr immédiatement après le
delete. Ainsi, toute tentative d’accès provoque un crash
immédiat et identifiable (plutôt qu’une corruption silencieuse), et un
delete sur nullptr est garanti sans effet par
le standard.
int* p = new int(5);
delete p;
p = nullptr;Cela reste une solution partielle — si plusieurs pointeurs partagent
la même adresse, les autres copies restent dangling. C’est
pourquoi les pointeurs intelligents
(unique_ptr, shared_ptr) sont la vraie
solution, comme on le verra dans la section suivante.
Quand on redimensionne un tableau dynamique manuellement, il faut :
Ancien tableau (@100) : [10 20 30]
Nouveau tableau (@320) : [10 20 30 40]
delete[] @100
Note: Le réallongement d’un tableau demande toujours une nouvelle allocation + copie, d’où le coût.
Les conteneurs modernes (std::vector) automatisent ce
processus efficacement.
L’allocation dynamique permet aussi de créer des structures chaînées ou hiérarchiques, où chaque élément contient des pointeurs vers d’autres.
struct Node {
int value;
Node* next;
};
int main() {
Node* n1 = new Node{5, nullptr};
Node* n2 = new Node{8, nullptr};
// Remarque : l'opérateur `->` permet d'accéder à un membre via un pointeur.
// `p->membre` est équivalent à `(*p).membre`.
n1->next = n2;
// parcours
for(Node* p = n1; p != nullptr; p = p->next)
std::cout << p->value << " ";
// libération
delete n2;
delete n1;
}Chaque élément (Node) est alloué séparément sur le tas.
Attention : il faut penser à libérer chaque élément
pour éviter les fuites.
En C++, on évite aujourd’hui new / delete
directs. On privilégie :
std::vector pour les tableaux dynamiquesExemple:
#include <vector>
#include <iostream>
std::vector<int> createVector(int n) {
std::vector<int> v(n);
for(int i=0; i<n; ++i)
v[i] = i * 10;
return v; // gestion automatique
}
int main() {
auto v = createVector(5);
for(int x : v)
std::cout << x << " ";
}→ La mémoire est gérée automatiquement (constructeur / destructeur).
std::unique_ptr,
std::shared_ptr)Les pointeurs intelligents sont des classes de la
bibliothèque standard C++ (<memory>) qui encapsulent
un pointeur brut (T*) et gèrent automatiquement la
durée de vie de la ressource pointée.
Ils suivent le principe du RAII : la ressource est
libérée automatiquement quand le pointeur sort de portée (destruction de
l’objet). Ainsi, plus besoin d’appeler delete manuellement
: la mémoire est libérée dès que l’objet n’est plus utilisé.
std::unique_ptrExemple:
#include <memory>
#include <iostream>
int main() {
std::unique_ptr<int> p = std::make_unique<int>(42);
std::cout << *p << std::endl;
} // delete automatique iciExplication :
std::unique_ptr<int> possède l’exclusivité de la
ressource : un seul pointeur gère l’objet alloué.std::make_unique<int>(42) crée dynamiquement un
int contenant 42 et renvoie un
unique_ptr qui en devient propriétaire.p sort de portée (fin du main), son
destructeur appelle automatiquement delete
sur l’objet qu’il gère.Caractéristiques de std::unique_ptr
:
std::shared_ptrExemple:
#include <memory>
#include <iostream>
int main() {
auto p1 = std::make_shared<int>(10);
auto p2 = p1; // partage de la ressource
std::cout << *p2 << std::endl;
} // mémoire libérée quand le dernier shared_ptr disparaîtExplication détaillée :
std::shared_ptr permet à plusieurs
pointeurs de partager la même ressource.p2 = p1;) augmente un compteur de
référence interne.shared_ptr est détruit, le compteur est
décrémenté.delete automatiquement
sur la ressource.Ainsi, la mémoire est libérée exactement quand elle n’est plus utilisée par personne.
Caractéristiques de std::shared_ptr
:
unique_ptr (compteur
atomique interne).std::unique_ptr<T> |
std::shared_ptr<T> |
|
|---|---|---|
| Copiable | Non | Oui |
| Partage | Non | Oui (compteur de références) |
| Destruction | À la sortie de portée | Quand le dernier propriétaire est détruit |
| Cas d’usage | Possession exclusive | Ressources partagées |
Cas unique_ptr :
+---------------------+
| unique_ptr<int> p |──► [42]
+---------------------+
│
delete automatique à la fin du bloc
Cas shared_ptr :
+---------------------+ +---------------------+
| shared_ptr<int> p1 |───┐ | compteur = 2 |
| shared_ptr<int> p2 |───┘──► [10]
+---------------------+ +---------------------+
│
delete automatique quand compteur = 0
new et
deletedelete.std::vector, std::map,
std::thread, etc.).En C et C++, on a souvent besoin de copier un bloc
d’octets (tableau, struct, buffer reçu du réseau/fichier,
etc.). La fonction standard pour ça est
memcpy, dans <string.h>
(C) ou <cstring> (C++).
#include <string.h>
void* memcpy(void* dest, const void* src, size_t n);src : adresse sourcedest : adresse destinationn : nombre d’octets copiésdest#include <stdio.h>
#include <string.h>
int main() {
int a[3] = {10, 20, 30};
int b[3] = {0, 0, 0};
memcpy(b, a, 3 * sizeof(int));
for(int i=0; i<3; ++i)
printf("%d ", b[i]); // 10 20 30
return 0;
}Ici, memcpy copie exactement
3 * sizeof(int) octets.
#include <stdio.h>
#include <string.h>
typedef struct {
int x;
int y;
} Point2D;
int main() {
Point2D p1 = {1, 2};
Point2D p2;
memcpy(&p2, &p1, sizeof(Point2D));
printf("%d %d\n", p2.x, p2.y); // 1 2
return 0;
}memcpyCas typique : on reçoit un tableau d’octets (réseau, fichier binaire, capteur…) et on veut en extraire des valeurs typées.
Supposons un message binaire au format suivant :
uint32_t idfloat temperatureuint16_t countSoit : 4 + 4 + 2 = 10 octets.
#include <stdint.h>
#include <stdio.h>
#include <string.h>
int main() {
// Buffer brut simulé (par ex. reçu du réseau)
uint8_t buf[10] = {
0xD2, 0x04, 0x00, 0x00, // id = 1234 en little-endian
0x00, 0x00, 0x48, 0x42, // float 50.0f en IEEE-754 (little-endian)
0x07, 0x00 // count = 7 en little-endian
};
size_t offset = 0;
uint32_t id;
float temp;
uint16_t count;
memcpy(&id, buf + offset, sizeof(uint32_t));
offset += sizeof(uint32_t);
memcpy(&temp, buf + offset, sizeof(float));
offset += sizeof(float);
memcpy(&count, buf + offset, sizeof(uint16_t));
offset += sizeof(uint16_t);
printf("id=%u, temp=%.2f, count=%u\n", id, temp, count);
return 0;
}void*En C et en C++, il existe un type de pointeur particulier :
void*, appelé pointeur
générique. Un void* peut contenir
l’adresse de n’importe quel type de donnée, sans
connaître sa nature.
Il représente donc une adresse brute, sans information de type associée.
void* p;Ici :
p peut stocker l’adresse d’un int, d’un
float, d’une struct, etc.p.Cela signifie que :
p,#include <stdio.h>
int main() {
int a = 42;
float b = 3.14f;
void* p;
p = &a; // p pointe vers un int
p = &b; // p pointe maintenant vers un float
return 0;
}Dans cet exemple :
p peut successivement contenir l’adresse de
a puis celle de b,Il est interdit de faire :
void* p = &a;
printf("%d\n", *p); // ERREURPourquoi ?
*p signifie « accéder à la valeur pointée »,Le type void signifie littéralement : absence
d’information de type.
Pour accéder à la valeur pointée, il faut convertir
explicitement le void* vers le bon type de
pointeur.
#include <stdio.h>
int main() {
int a = 42;
void* p = &a;
int* pi = (int*)p; // cast explicite
printf("%d\n", *pi); // OK
return 0;
}Étapes :
p contient l’adresse de a,int* »,#include <stdio.h>
void print_value(void* data, char type)
{
if (type == 'i') {
printf("int : %d\n", *(int*)data);
}
else if (type == 'f') {
printf("float : %f\n", *(float*)data);
}
}
int main() {
int a = 10;
float b = 2.5f;
print_value(&a, 'i');
print_value(&b, 'f');
return 0;
}Ici :
void* permet de passer n’importe quel
type,Contrairement aux autres pointeurs (int*,
double*, etc.), l’arithmétique des pointeurs est
interdite sur void* en C++.
void* p;
p + 1; // ERREUR en C++Raison :
p + 1 nécessite de connaître
sizeof(type),void n’a pas de taille.En C (mais pas en C++), certains compilateurs autorisent
void* comme une extension non standard, en le traitant
comme un char*.
void* et tableaux /
mémoire bruteLe void* est souvent utilisé pour manipuler de la
mémoire brute, par exemple avec malloc,
memcpy, ou des APIs bas niveau.
Exemple :
#include <stdlib.h>
int main() {
void* buffer = malloc(100); // 100 octets de mémoire brute
// interprétation explicite
int* tab = (int*)buffer;
tab[0] = 42;
free(buffer);
return 0;
}Ici :
malloc renvoie un void*,void*Voici un exemple typique d’utilisation de void* : on
reçoit un bloc d’octets brut (réseau, fichier, trame capteur, image, …),
stocké dans un void*, puis on reconstruit une structure
“interprétable”.
Imaginons un serveur qui envoie un message binaire composé de :
un en-tête (header) avec :
uint32_t iduint16_t widthuint16_t heightpuis des données (payload) : ici, par exemple, une image en
niveaux de gris de taille width * height octets.
On reçoit l’information comme un buffer brut
(typiquement void* + taille) que l’on doit
“restructurer”.
uint8_t*
pour faire de l’arithmétique en octets),#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#pragma pack(push, 1) // pour éviter le padding (dépendant compilateur/ABI)
typedef struct {
uint32_t id;
uint16_t width;
uint16_t height;
} Header;
#pragma pack(pop)
int main() {
// --- Simulation : "réception réseau" d'un bloc brut ---
// On fabrique un buffer qui contient : Header + pixels
Header h = { .id = 1234, .width = 4, .height = 3 };
uint8_t pixels[12] = {
10, 20, 30, 40,
50, 60, 70, 80,
90,100,110,120
};
size_t total = sizeof(Header) + sizeof(pixels);
void* buffer = malloc(total);
memcpy(buffer, &h, sizeof(Header));
memcpy((uint8_t*)buffer + sizeof(Header), pixels, sizeof(pixels));
// --- Reconstruction / interprétation ---
// 1) Lire l'en-tête
Header header;
memcpy(&header, buffer, sizeof(Header));
printf("id=%u, width=%u, height=%u\n",
header.id, header.width, header.height);
// 2) Accéder au payload (image) après l'en-tête
size_t image_size = (size_t)header.width * (size_t)header.height;
// Vérification minimale de cohérence
if (sizeof(Header) + image_size > total) {
printf("Buffer incomplet ou corrompu !\n");
free(buffer);
return 1;
}
uint8_t* image = (uint8_t*)buffer + sizeof(Header);
// Exemple : afficher les pixels (ligne par ligne)
for (uint16_t y = 0; y < header.height; ++y) {
for (uint16_t x = 0; x < header.width; ++x) {
printf("%3u ", image[y * header.width + x]);
}
printf("\n");
}
free(buffer);
return 0;
}Le void* est principalement utilisé :
qsort,
bsearch).En C++ moderne, on préfère :
std::vector,
std::array),std::unique_ptr,
std::shared_ptr).Dans les exemples précédents, nous avons utilisé le cast
C pour convertir un void* vers un type concret
:
int* pi = (int*)p; // cast C-styleCe cast fonctionne, mais il est dangereux : le compilateur n’effectue aucune vérification. Il accepte silencieusement des conversions absurdes, ce qui peut provoquer des bugs difficiles à détecter.
C++ introduit quatre opérateurs de cast explicites,
chacun avec un rôle bien défini. Leur syntaxe verbeuse est volontaire :
elle rend les conversions visibles dans le code et
facilite leur recherche (on peut chercher _cast dans tout
un projet).
static_cast
— conversion entre types compatiblesC’est le cast le plus courant. Il couvre les
conversions classiques : entre types numériques, de void*
vers un type pointeur, etc.
double d = 3.14;
int i = static_cast<int>(d); // 3 (troncature)
void* p = &i;
int* pi = static_cast<int*>(p); // conversion void* → int*Le compilateur vérifie que la conversion est raisonnable (types compatibles). C’est le cast à utiliser par défaut.
dynamic_cast
— cast polymorphique (héritage)Permet de convertir un pointeur de base vers un pointeur dérivé, avec
vérification à l’exécution (RTTI — Run-Time Type
Information). Nécessite au moins une méthode virtual
dans la classe de base.
struct Base { virtual ~Base() {} };
struct Derived : Base { int x; };
Base* b = new Derived();
Derived* d = dynamic_cast<Derived*>(b);
// d != nullptr si b pointe bien sur un objet DerivedSi la conversion est invalide, dynamic_cast retourne
nullptr (pour les pointeurs) ou lève
std::bad_cast (pour les références), au lieu de provoquer
un comportement indéfini.
const_cast —
ajouter ou retirer constSert à retirer (ou ajouter) le qualificatif const d’un
pointeur ou d’une référence. Usage rare, principalement pour interfacer
du code C++ avec des APIs C anciennes qui n’utilisent pas
const.
void legacy_api(char* s); // API C sans const
const char* msg = "hello";
legacy_api(const_cast<char*>(msg)); // retire const pour l'appelAttention : modifier un objet réellement déclaré const
après un const_cast est un comportement
indéfini.
reinterpret_cast
— réinterprétation brute de la mémoireRéinterprète les bits d’un pointeur comme un autre type, sans aucune vérification. Utile pour le travail sur des buffers binaires (réseau, fichiers, images) où l’on doit accéder octet par octet à la mémoire.
int a = 42;
char* bytes = reinterpret_cast<char*>(&a);
// Accès octet par octet à la représentation mémoire de aC’est le cast le plus dangereux : il signale un code qui manipule la
mémoire à bas niveau. À n’utiliser que quand static_cast ne
suffit pas.
| Cast | Usage | Sûreté |
|---|---|---|
static_cast |
Conversions classiques entre types compatibles | Vérifié à la compilation |
dynamic_cast |
Downcast polymorphique (héritage) | Vérifié à l’exécution |
const_cast |
Ajouter/retirer const |
Rare, potentiellement dangereux |
reinterpret_cast |
Réinterprétation brute de la mémoire | Aucune vérification |
Règle pratique : préférer
static_castdans la grande majorité des cas. Si vous avez besoin deconst_castoureinterpret_cast, c’est souvent le signe que le design mérite d’être reconsidéré.
En C++, les références sont introduites comme une alternative plus simple et plus sûre aux pointeurs. On peut les voir comme un alias vers une variable existante, et surtout comme un sucre syntaxique au-dessus de la notion de pointeur :
*
ou -> : la référence se manipule comme la variable
elle-même.Comme vu précédemment, le passage par valeur crée une copie de l’argument — la variable originale n’est jamais modifiée. Comparons les trois approches :
#include <iostream>
void ma_fonction(int* b) {
*b = *b + 2; // modifie la valeur pointée
}
int main() {
int a = 5;
ma_fonction(&a); // on passe l'adresse de a
std::cout << a << std::endl; // affiche 7
}Ici :
b est une copie du pointeur vers a.*b pour accéder/modifier la
valeur.*).#include <iostream>
void ma_fonction(int& b) {
b = b + 2; // on a l'impression de manipuler b comme une variable
}
int main() {
int a = 5;
ma_fonction(a); // pas de &
std::cout << a << std::endl; // affiche 7
}Ici :
b est une référence alias de
a.b comme s’il
s’agissait d’une variable locale.Une référence doit toujours être initialisée au moment de sa déclaration :
int main() {
int a = 5;
int& ref_a = a; // OK : ref_a est un alias de a
ref_a = 9; // modifie a
int& ref_b; // ERREUR : une référence doit être initialisée
}Contrairement à un pointeur, une référence :
Une référence constante (const &)
permet de :
#include <iostream>
#include <string>
void printMessage(const std::string& msg) {
std::cout << msg << std::endl;
}
int main() {
std::string text = "Bonjour";
printMessage(text); // pas de copie, et sécurité garantie
}Les références constantes sont largement utilisées pour passer des objets volumineux (vecteurs, chaînes, structures) sans copie.
#include <iostream>
struct vec4 {
double x, y, z, w;
};
// passage par référence pour modifier
void multiply(vec4& v, double s) {
v.x *= s; v.y *= s; v.z *= s; v.w *= s;
}
// passage par référence constante pour éviter une copie
void print(const vec4& v) {
std::cout << v.x << " " << v.y << " " << v.z << " " << v.w << std::endl;
}
int main() {
vec4 v = {1.1, 2.2, 3.3, 4.4};
multiply(v, 2.0); // modifie v
print(v); // affiche sans recopier
}En C++, les références sont très pratiques pour écrire des accesseurs :
class Vec50 {
private:
float T[50];
public:
void init() {
for(int k=0; k<50; ++k)
T[k] = static_cast<float>(k);
}
// accesseur read-only
float value(unsigned int i) const {
return T[i];
}
// accesseur read/write : retourne une référence
float& value(unsigned int i) {
return T[i];
}
};
int main() {
Vec50 v;
v.init();
std::cout << v.value(10) << std::endl; // lecture
v.value(10) = 42; // écriture via référence
std::cout << v.value(10) << std::endl;
}À faire
const & pour passer des objets lourds
(vecteurs, chaînes, classes).set).À éviter
En C++, une classe permet de regrouper dans une même entité des données (appelées attributs) et des fonctions (appelées méthodes) qui manipulent ces données. Une instance d’une classe est appelée un objet. Cette organisation facilite la structuration du code, sa lisibilité et sa maintenance.
structOn commence souvent par une struct pour représenter un
objet simple :
struct vec3 {
float x;
float y;
float z;
};Ici, vec3 regroupe trois valeurs représentant un vecteur
3D. Les membres sont publics par défaut, ce qui
signifie qu’ils sont accessibles directement :
vec3 v;
v.x = 1.0f;
v.y = 2.0f;
v.z = 3.0f;Ce type de structure est bien adapté pour des agrégats de données simples, très fréquents en informatique graphique.
Une classe ou une struct peut aussi contenir des fonctions membres :
#include <cmath>
struct vec3 {
float x, y, z;
float norm() const {
return std::sqrt(x*x + y*y + z*z);
}
};La méthode norm() opère directement sur les attributs
x, y et z de l’objet :
vec3 v{1.0f, 2.0f, 2.0f};
float n = v.norm(); // n = 3Remarque : le const placé après la signature d’une
méthode (ici norm() const) indique que la méthode
ne modifie pas l’état de l’objet. Une méthode
const peut être appelée sur un objet const, et
le compilateur interdit toute modification des membres non
mutable à l’intérieur de cette méthode.
thisDans les méthodes d’une classe, le compilateur fournit
implicitement un pointeur nommé this qui
pointe vers l’objet courant. Il est utile pour accéder explicitement aux
membres, désambiguïser des paramètres et retourner une référence sur
l’objet.
Exemple :
struct S {
int x;
void set(int x) { this->x = x; } // désambiguïse le champ x
int get() const { return this->x; } // this est const
};Cette notion est basique mais importante : this permet
de manipuler l’objet courant à l’intérieur des méthodes et rend
explicite certaines opérations (transfert de ownership, retour
de *this, …).
struct vs
classLe mot-clé class fonctionne exactement comme
struct, à la différence que : les membres sont
privés par défaut.
class vec3 {
float x, y, z; // privés par défaut
};Ce code ne compile pas :
vec3 v;
v.x = 1.0f; // ERREUR : x est privéPour rendre certains membres accessibles, il faut préciser les niveaux d’accès.
On utilise les mots-clés public et private
pour contrôler l’accès aux membres :
class vec3 {
public:
vec3(float x_, float y_, float z_) : x(x_), y(y_), z(z_) {}
float norm() const {
return std::sqrt(x*x + y*y + z*z);
}
private:
float x, y, z;
};Utilisation :
vec3 v(1.0f, 2.0f, 2.0f);
float n = v.norm(); // OK
// v.x = 3.0f; // ERREUR : x est privéIci :
Grâce à cette encapsulation, l’objet garantit sa cohérence interne. Par exemple, on peut forcer certaines règles :
class Circle {
public:
Circle(float radius) {
set_radius(radius);
}
float area() const {
return 3.14159f * r * r;
}
void set_radius(float radius) {
if (radius > 0.0f)
r = radius;
}
private:
float r;
};Ici, le rayon ne peut jamais devenir négatif, car l’accès direct à
r est interdit.
En pratique, on réservera struct aux agrégats de données
simples (sans invariants complexes), et class dès qu’il y a
besoin d’encapsulation ou de contrôle d’accès.
En C++, l’initialisation d’un objet est prise en charge par les constructeurs. Un constructeur est une fonction spéciale (même nom que la classe, pas de type de retour) appelée automatiquement à la création de l’objet. Son but est de garantir que l’objet est dans un état valide dès le départ.
Si une classe/struct contient des types fondamentaux
(int, float, etc.), ils ne sont pas forcément
initialisés automatiquement.
#include <iostream>
struct vec3 {
float x, y, z;
};
int main() {
vec3 v; // x,y,z indéfinis !
std::cout << v.x << std::endl; // comportement indéterminé
}Dans le cas d’une struct agrégée, on peut forcer une
initialisation à zéro avec {} :
vec3 v{}; // x=y=z=0Mais dès qu’on veut contrôler précisément l’état de l’objet, on utilise des constructeurs.
Le constructeur par défaut ne prend aucun argument. Il sert souvent à mettre des valeurs cohérentes.
struct vec3 {
float x, y, z;
vec3() : x(0.0f), y(0.0f), z(0.0f) {}
};
int main() {
vec3 v; // appelle vec3()
}Ici, v est garanti valide : ses champs valent 0.
L’écriture : x(...), y(...), z(...) est la liste
d’initialisation. Elle initialise les attributs avant d’entrer dans le
corps du constructeur.
struct vec3 {
float x, y, z;
vec3(float x_, float y_, float z_) : x(x_), y(y_), z(z_) {}
};Utilisation :
vec3 v(1.0f, 2.0f, 3.0f);
vec3 w{1.0f, 2.0f, 3.0f}; // uniforme (souvent recommandé)Cette liste est préférable à une affectation dans le corps du constructeur, car elle évite une “double étape” (construction puis réaffectation) et elle est requise pour certains membres.
On peut définir plusieurs constructeurs pour offrir différentes manières de créer un objet.
struct vec3 {
float x, y, z;
vec3() : x(0), y(0), z(0) {}
vec3(float v) : x(v), y(v), z(v) {}
vec3(float x_, float y_, float z_) : x(x_), y(y_), z(z_) {}
};
int main() {
vec3 a; // (0,0,0)
vec3 b(1.0f); // (1,1,1)
vec3 c(1.0f,2.0f,3.0f); // (1,2,3)
}explicitUn constructeur à un seul argument peut servir de conversion
implicite, ce qui peut provoquer des effets de bord. Le mot-clé
explicit empêche ces conversions automatiques.
struct vec3 {
float x, y, z;
explicit vec3(float v) : x(v), y(v), z(v) {}
};vec3 a(1.0f); // OK
// vec3 b = 1.0f; // interdit grâce à explicitCela rend le code plus sûr et plus lisible.
const et références : constructeur obligatoireLes attributs const et les références doivent être
initialisés via la liste d’initialisation.
struct sample {
int const id;
float& ref;
sample(int id_, float& ref_) : id(id_), ref(ref_) {}
};Sans liste d’initialisation, ce code ne compile pas, car
id et ref ne peuvent pas être “assignés” après
coup : ils doivent être initialisés immédiatement.
Le destructeur est appelé automatiquement quand l’objet est détruit
(fin de scope, delete, etc.). Il sert surtout à libérer des
ressources (fichier, mémoire, GPU…).
#include <iostream>
struct tracer {
tracer() { std::cout << "Constructed\n"; }
~tracer() { std::cout << "Destroyed\n"; }
};
int main() {
tracer t; // "Constructed"
} // "Destroyed"On retiendra qu’il est préférable d’initialiser systématiquement les
attributs (via constructeur ou {}), de privilégier la liste
d’initialisation :, et d’utiliser explicit
pour les constructeurs à un argument sauf si la conversion implicite est
voulue.
En C++, il est possible de surcharger des opérateurs
pour des classes et des structures afin de rendre leur utilisation plus
naturelle et expressive. Cette fonctionnalité est particulièrement utile
en informatique graphique, où l’on manipule fréquemment des vecteurs,
matrices, couleurs ou transformations, et où des expressions comme
v1 + v2 ou 2.0f * v sont bien plus lisibles
qu’un appel de fonction explicite.
La surcharge d’opérateurs consiste à définir une fonction
spéciale dont le nom est operator<symbole>.
Du point de vue du compilateur, une expression comme :
a + best traduite en :
operator+(a, b);ou, dans le cas d’un opérateur membre :
a.operator+(b);La surcharge ne crée pas de nouvel opérateur : elle redéfinit simplement le comportement d’un opérateur existant pour un type donné.
Un opérateur peut être défini :
Règle courante :
+=, *=, [], etc.) sont souvent
des méthodes membres ;+, -,
*) sont souvent des fonctions non-membres.struct vec3 {
float x, y, z;
vec3() : x(0), y(0), z(0) {}
vec3(float x_, float y_, float z_) : x(x_), y(y_), z(z_) {}
vec3& operator+=(vec3 const& v) {
x += v.x;
y += v.y;
z += v.z;
return *this;
}
};L’opérateur += modifie l’objet courant et retourne une
référence sur celui-ci.
On définit ensuite + comme opérateur non-membre en
réutilisant += :
vec3 operator+(vec3 a, vec3 const& b) {
a += b;
return a;
}Utilisation :
vec3 a{1,2,3};
vec3 b{4,5,6};
vec3 c = a + b; // (5,7,9)
a += b; // a devient (5,7,9)On peut définir des opérateurs entre types différents, par exemple la multiplication par un scalaire :
vec3 operator*(float s, vec3 const& v) {
return vec3{s*v.x, s*v.y, s*v.z};
}
vec3 operator*(vec3 const& v, float s) {
return s * v;
}Cela permet une écriture naturelle :
vec3 v{1,2,3};
vec3 w = 2.0f * v;Les opérateurs de comparaison permettent de comparer des objets :
bool operator==(vec3 const& a, vec3 const& b) {
return a.x == b.x && a.y == b.y && a.z == b.z;
}
bool operator!=(vec3 const& a, vec3 const& b) {
return !(a == b);
}Depuis C++20, il existe également l’opérateur <=>
(three-way comparison), mais son utilisation dépasse le cadre de cette
introduction.
[]L’opérateur [] est souvent utilisé pour donner un accès
indexé aux données internes :
struct vec3 {
float x, y, z;
float& operator[](int i) {
return (&x)[i]; // accès contigu
}
float const& operator[](int i) const {
return (&x)[i];
}
};Utilisation :
vec3 v{1,2,3};
v[0] = 4.0f;
float y = v[1];La version const est indispensable pour permettre
l’accès en lecture sur un objet constant.
<<Pour faciliter le débogage, on surcharge souvent l’opérateur
<< avec std::ostream :
#include <iostream>
std::ostream& operator<<(std::ostream& out, vec3 const& v) {
out << "(" << v.x << ", " << v.y << ", " << v.z << ")";
return out;
}Utilisation :
vec3 v{1,2,3};
std::cout << v << std::endl;Quelques principes à suivre : toujours passer les paramètres en
lecture par référence constante, retourner
*this par référence pour les opérateurs de modification
(+=, *=, etc.), et ne pas surcharger un
opérateur si son sens mathématique ou logique n’est pas clair. La
surcharge d’opérateurs permet d’écrire du code plus lisible, mais elle
doit rester simple, cohérente et prévisible.
L’héritage est un mécanisme central de la programmation orientée objet qui permet de définir une nouvelle classe à partir d’une classe existante. La classe dérivée hérite des attributs et des méthodes de la classe de base, ce qui favorise la réutilisation du code et la structuration hiérarchique des concepts. En C++, l’héritage est souvent utilisé pour factoriser des comportements communs tout en permettant des spécialisations.
On définit une classe dérivée en indiquant la classe de base après
: :
class Derived : public Base {
// contenu spécifique à Derived
};Le mot-clé public indique que l’interface publique de la
classe de base reste publique dans la classe dérivée. C’est le cas le
plus courant et celui utilisé dans la majorité des conceptions orientées
objet.
Considérons une classe de base représentant une forme géométrique :
class Shape {
public:
float x, y;
Shape(float x_, float y_) : x(x_), y(y_) {}
void translate(float dx, float dy) {
x += dx;
y += dy;
}
};On peut définir une classe dérivée qui spécialise ce comportement :
class Circle : public Shape {
public:
float radius;
Circle(float x_, float y_, float r_)
: Shape(x_, y_), radius(r_) {}
};Utilisation :
Circle c(0.0f, 0.0f, 1.0f);
c.translate(1.0f, 2.0f); // méthode héritée de ShapeLa classe Circle hérite automatiquement de
x, y et de la méthode
translate.
Le constructeur de la classe dérivée doit appeler explicitement le constructeur de la classe de base dans sa liste d’initialisation.
Circle(float x_, float y_, float r_)
: Shape(x_, y_), radius(r_) {}Si le constructeur de la classe de base n’est pas appelé explicitement, le compilateur tentera d’appeler le constructeur par défaut, ce qui peut entraîner une erreur s’il n’existe pas.
public, protected, privateLe niveau d’accès des membres de la classe de base détermine leur visibilité dans la classe dérivée :
public : accessible partout, y compris dans les classes
dérivées.protected : accessible uniquement dans la classe et ses
dérivées.private : accessible uniquement dans la classe de
base.Exemple :
class Shape {
protected:
float x, y;
public:
Shape(float x_, float y_) : x(x_), y(y_) {}
};class Circle : public Shape {
public:
float radius;
Circle(float x_, float y_, float r_)
: Shape(x_, y_), radius(r_) {}
float center_x() const {
return x; // autorisé car x est protected
}
};Une classe dérivée peut redéfinir une méthode de la classe de base afin de fournir un comportement spécifique.
class Shape {
public:
float x, y;
Shape(float x_, float y_) : x(x_), y(y_) {}
float area() const {
return 0.0f;
}
};class Rectangle : public Shape {
public:
float w, h;
Rectangle(float x_, float y_, float w_, float h_)
: Shape(x_, y_), w(w_), h(h_) {}
float area() const {
return w * h;
}
};Ici, Rectangle::area masque la version définie dans
Shape. Ce mécanisme prépare naturellement l’introduction du
polymorphisme, qui sera étudié dans le chapitre
suivant.
L’héritage permet d’éviter les duplications :
class Vehicle {
public:
float speed;
void accelerate(float dv) {
speed += dv;
}
};
class Car : public Vehicle {
// comportement spécifique
};
class Plane : public Vehicle {
// comportement spécifique
};Les classes Car et Plane partagent le même
comportement de base sans duplication.
L’héritage sert à exprimer une relation est-un (is-a). On veillera à garder les classes de base simples et stables.
Le polymorphisme permet de manipuler des objets de types différents à travers une interface commune, tout en appelant automatiquement la bonne implémentation selon le type réel de l’objet. En C++, il repose sur l’héritage, les fonctions virtuelles et l’utilisation de pointeurs ou références vers une classe de base. Il est particulièrement utile lorsqu’on souhaite stocker des objets hétérogènes dans un même conteneur et les traiter de manière uniforme.
Supposons que l’on souhaite représenter différentes formes géométriques et calculer leur aire totale.
struct Circle {
float r;
float area() const {
return 3.14159f * r * r;
}
};
struct Rectangle {
float w, h;
float area() const {
return w * h;
}
};Ces deux types possèdent une méthode area(), mais
ils n’ont aucun lien de type. Il est donc impossible
d’écrire :
std::vector<Circle> shapes; // uniquement des cercles
std::vector<Rectangle> shapes; // uniquement des rectangleset surtout impossible de faire :
std::vector</* Circle et Rectangle */> shapes; // impossibleSans polymorphisme, on est contraint soit :
Le polymorphisme fournit une solution élégante à ce problème.
On commence par définir une classe de base représentant le concept général de “forme” :
class Shape {
public:
virtual float area() const = 0; // méthode virtuelle pure
virtual ~Shape() = default;
};Cette classe est abstraite :
Chaque forme concrète hérite de Shape et implémente
area() :
// Remarque : le mot-clé `override` (C++11) indique au compilateur
// que la méthode redéfinit une méthode virtuelle de la classe de base.
// Il provoquera une erreur de compilation si la signature ne correspond pas.
class Circle : public Shape {
public:
float r;
explicit Circle(float r_) : r(r_) {}
float area() const override {
return 3.14159f * r * r;
}
};class Rectangle : public Shape {
public:
float w, h;
Rectangle(float w_, float h_) : w(w_), h(h_) {}
float area() const override {
return w * h;
}
};Grâce à l’héritage et aux fonctions virtuelles, on peut maintenant stocker des pointeurs vers la classe de base dans un même conteneur :
#include <vector>
#include <memory>
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0f));
shapes.push_back(std::make_unique<Rectangle>(3.0f, 4.0f));
float total_area = 0.0f;
for (auto const& s : shapes) {
total_area += s->area(); // appel polymorphique
}
}Ici :
Shape,area() est résolu
dynamiquement selon le type réel (Circle
ou Rectangle).virtual et du dispatch dynamiqueL’appel :
s->area();est résolu à l’exécution grâce à la table virtuelle :
s pointe vers un Circle,
Circle::area() est appelée,s pointe vers un Rectangle,
Rectangle::area() est appelée.C’est le cœur du polymorphisme dynamique.
Les objets sont détruits via un pointeur vers la classe de base. Le destructeur doit donc être virtuel :
class Shape {
public:
virtual ~Shape() = default;
};Sans cela, le destructeur de la classe dérivée ne serait pas appelé, ce qui pourrait provoquer des fuites de ressources.
On ne peut pas stocker directement des objets dérivés dans un
conteneur de type std::vector<Shape> car cela
entraînerait un slicing (perte de la partie dérivée).
Les pointeurs (souvent intelligents) évitent ce problème et permettent
la liaison dynamique.
Le polymorphisme dynamique implique :
Dans des boucles très critiques en performance, on privilégiera parfois le polymorphisme statique via les templates, abordé ultérieurement.
Dans les exemples précédents, nous avons utilisé des
pointeurs intelligents (std::unique_ptr)
pour gérer automatiquement la durée de vie des objets. Il est toutefois
important de comprendre que le polymorphisme en C++ fonctionne
historiquement avec des pointeurs bruts
(Shape*). Ceux-ci offrent plus de liberté, mais exigent une
gestion manuelle de la mémoire, ce qui augmente
fortement le risque d’erreurs.
#include <vector>
int main() {
std::vector<Shape*> shapes;
shapes.push_back(new Circle(2.0f));
shapes.push_back(new Rectangle(3.0f, 4.0f));
float total_area = 0.0f;
for (Shape* s : shapes) {
total_area += s->area(); // appel polymorphique
}
// Libération manuelle de la mémoire
for (Shape* s : shapes) {
delete s;
}
}Ici :
new,Shape,area() sont résolus dynamiquement,delete.Avec des pointeurs bruts, le destructeur virtuel est absolument indispensable :
class Shape {
public:
virtual ~Shape() = default;
};Sans destructeur virtuel, l’appel :
delete s;ne détruirait que la partie Shape de l’objet, et non la
partie dérivée (Circle, Rectangle), entraînant
des fuites de ressources et un comportement indéfini.
L’utilisation de pointeurs bruts expose à plusieurs erreurs classiques :
delete → fuite mémoire ;delete → comportement indéfini
;Ces problèmes sont difficiles à détecter et à corriger, en particulier dans des projets de grande taille.
Pour résumer : le polymorphisme sert au traitement uniforme
d’objets hétérogènes. On définira des classes de base
abstraites comme interfaces, on déclarera systématiquement un
destructeur virtuel, on utilisera override pour sécuriser
les redéfinitions, et on combinera le tout avec des pointeurs
intelligents (std::unique_ptr). Ce mécanisme permet de
concevoir des systèmes extensibles où de nouveaux types peuvent être
ajoutés sans modifier le code existant.
constEn C++, le mot-clé const appliqué aux
méthodes de classe joue un rôle central dans la gestion
des accès et dans la sécurité du code. Il ne s’agit pas d’un simple
indicateur documentaire : une méthode const et une méthode
non const sont considérées par le compilateur comme
deux méthodes différentes, pouvant parfaitement
cohabiter dans une même classe avec le même nom.
constUne méthode déclarée avec const après sa signature
garantit qu’elle ne modifie pas l’état de l’objet.
class vec3 {
public:
float x, y, z;
float norm() const {
return std::sqrt(x*x + y*y + z*z);
}
};Le const signifie ici que la méthode ne peut pas
modifier x, y ou z. Toute
tentative de modification provoquerait une erreur de compilation.
float norm() const {
x = 0.0f; // ERREUR : modification interdite
return 0.0f;
}Un objet déclaré const ne peut appeler que des
méthodes const.
const vec3 v{1.0f, 2.0f, 3.0f};
v.norm(); // OK
// v.normalize(); // ERREUR si normalize() n'est pas constCela impose naturellement une séparation claire entre :
const et non const : deux signatures
différentesUne méthode const et une méthode non const
portant le même nom ne sont pas la même fonction. Elles
peuvent être définies simultanément dans une classe.
class vec3 {
public:
float x, y, z;
float& operator[](int i) {
return (&x)[i];
}
float const& operator[](int i) const {
return (&x)[i];
}
};Ici :
const est appelée sur
un objet modifiable,const est appelée sur un
objet constant.Utilisation :
vec3 a{1,2,3};
a[0] = 5.0f; // appelle la version non const
const vec3 b{1,2,3};
float x = b[0]; // appelle la version constLe compilateur choisit automatiquement la version appropriée en fonction du caractère const de l’objet.
class Buffer {
public:
float& value() {
return data;
}
float value() const {
return data;
}
private:
float data;
};Ici :
value() (non const) permet de modifier la
donnée,value() const permet seulement de la lire.Buffer b;
b.value() = 3.0f; // version non const
const Buffer c;
// c.value() = 3.0f; // ERREUR
float v = c.value(); // version constCette distinction permet :
Dans une conception bien structurée, la majorité des méthodes
devraient être const. Les méthodes non const
correspondent à des opérations de modification
explicites.
En règle générale, toute méthode qui ne modifie pas l’objet devrait
être marquée const. Quand l’accès peut être en lecture ou
en écriture, on fournit les deux versions. const est avant
tout un outil de conception, pas une simple contrainte syntaxique.
static dans les classesLe mot-clé static, appliqué aux membres
d’une classe, modifie profondément leur nature et leur
durée de vie. Un membre static
n’appartient pas à un objet, mais à la classe
elle-même. Il est donc partagé par toutes les
instances de cette classe. Ce mécanisme est essentiel pour
représenter des données ou des comportements globaux liés à un concept,
plutôt qu’à un objet particulier.
Un attribut statique est unique pour toute la classe, quel que soit le nombre d’objets créés.
class Counter {
public:
Counter() {
++count;
}
static int get_count() {
return count;
}
private:
static int count;
};La déclaration dans la classe ne suffit pas.
L’attribut statique doit être défini une seule fois
dans un fichier .cpp :
int Counter::count = 0;Utilisation :
Counter a;
Counter b;
Counter c;
int n = Counter::get_count(); // n = 3Tous les objets Counter partagent la même
variable count.
Un attribut statique :
Counter::get_count(); // forme recommandéeCela souligne le fait que la donnée appartient à la classe, et non à une instance particulière.
Une méthode statique est une fonction associée à la classe, mais indépendante de toute instance.
class MathUtils {
public:
static float square(float x) {
return x * x;
}
};Utilisation :
float y = MathUtils::square(3.0f);Une méthode statique :
this,class Example {
public:
static void f() {
// x = 3; // ERREUR : x n'est pas statique
y = 4; // OK
}
private:
int x;
static int y;
};static et
initialisationDepuis C++17, il est possible d’initialiser directement certains
attributs statiques dans la classe s’ils sont constexpr ou
de type littéral.
class Physics {
public:
static constexpr float gravity = 9.81f;
};Utilisation :
float g = Physics::gravity;Dans ce cas, aucune définition supplémentaire dans un
.cpp n’est nécessaire.
Le mot-clé static est utilisé pour :
Exemple : identifiant unique par objet
class Object {
public:
Object() : id(next_id++) {}
int get_id() const {
return id;
}
private:
int id;
static int next_id;
};
int Object::next_id = 0;Chaque objet reçoit un identifiant unique, généré à partir d’un compteur partagé.
On accèdera aux membres statiques via NomClasse::membre,
on limitera l’usage des attributs statiques modifiables pour éviter les
dépendances cachées, et on préfèrera constexpr static pour
les constantes connues à la compilation.
Un membre
staticest unique et partagé : il appartient à la classe, pas aux objets.
namespace)Quand un projet grandit, il devient fréquent d’avoir des noms
identiques dans des parties différentes du code :
vec3, add, normalize,
load, etc. En C++, un espace de noms
(namespace) permet de regrouper des fonctions,
types et constantes sous un préfixe commun, afin de :
L’exemple le plus connu est la bibliothèque standard :
std::vector, std::string,
std::cout.
Un namespace crée une “boîte” logique :
namespace math {
struct vec3 {
float x, y, z;
};
float dot(vec3 const& a, vec3 const& b)
{
return a.x*b.x + a.y*b.y + a.z*b.z;
}
} // namespace mathUtilisation :
math::vec3 a{1,2,3};
math::vec3 b{4,5,6};
float p = math::dot(a, b);Ici, math:: est le qualificateur : il
désambiguïse les symboles.
Deux bibliothèques peuvent proposer une fonction load()
mais pour des usages différents. Sans namespace, cela devient
ambigu.
namespace io {
int load(char const* filename) { /* ... */ return 0; }
}
namespace gpu {
int load(char const* shader_file) { /* ... */ return 1; }
}Usage explicite et non ambigu :
int a = io::load("mesh.obj");
int b = gpu::load("shader.vert");using :
importer des noms (avec prudence)Il existe deux syntaxes :
using math::vec3;
vec3 v{1,2,3}; // équivalent à math::vec3using namespace std;Cela permet d’écrire vector au lieu de
std::vector, mais peut créer des conflits.
Bonne pratique :
using namespace ...; est acceptable dans un petit
.cpp local,.hpp, car il pollue
tous les fichiers qui incluent cet en-tête.On peut structurer par modules :
namespace engine {
namespace math {
struct vec2 { float x, y; };
}
namespace io {
void save();
}
}Depuis C++17, on peut écrire plus simplement :
namespace engine::math {
struct vec2 { float x, y; };
}Un namespace anonyme rend les symboles visibles uniquement
dans le fichier courant (équivalent à static pour
des fonctions globales, mais plus général).
namespace {
int helper(int x) { return 2*x; }
}
int f(int a)
{
return helper(a);
}Intérêt :
Utile si un nom est long :
namespace em = engine::math;
em::vec2 v{1,2};En C++, les fonctions peuvent être manipulées comme des valeurs : on peut les stocker dans des variables, les passer en argument à d’autres fonctions, ou les retourner. Ce mécanisme est fondamental pour écrire du code générique et flexible (callbacks, stratégies, algorithmes paramétrables).
Un pointeur de fonction est une variable qui contient l’adresse d’une fonction. Comme un pointeur classique désigne une variable en mémoire, un pointeur de fonction désigne le code exécutable d’une fonction.
#include <iostream>
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
int main() {
int (*op)(int, int); // déclare un pointeur vers une fonction (int, int) -> int
op = add;
std::cout << op(3, 4) << std::endl; // 7
op = mul;
std::cout << op(3, 4) << std::endl; // 12
}La syntaxe de déclaration int (*op)(int, int) se lit :
op est un pointeur (*) vers une fonction
prenant deux int et retournant un int.
Un alias de type rend le code plus lisible :
using BinaryOp = int (*)(int, int);
BinaryOp op = add;
std::cout << op(3, 4) << std::endl;Les pointeurs de fonctions permettent d’écrire des fonctions qui acceptent un comportement en paramètre :
void apply_to_array(int* data, int size, int (*f)(int)) {
for (int i = 0; i < size; ++i)
data[i] = f(data[i]);
}
int doubler(int x) { return 2 * x; }
int negate(int x) { return -x; }
int main() {
int arr[] = {1, 2, 3, 4};
apply_to_array(arr, 4, doubler); // arr = {2, 4, 6, 8}
apply_to_array(arr, 4, negate); // arr = {-2, -4, -6, -8}
}C’est le même principe qu’utilisent les algorithmes de la
bibliothèque standard (std::sort,
std::transform, etc.) pour accepter des critères
personnalisés.
Les pointeurs de fonctions ont deux limitations importantes :
static de classe), pas des méthodes
d’instance,int seuil = 10;
// Impossible : un pointeur de fonction ne peut pas "voir" seuil
// bool above_seuil(int x) { return x > seuil; } // seuil doit être globalCes limitations motivent l’introduction des foncteurs et des lambdas.
Un foncteur est un objet dont la classe surcharge
l’opérateur operator(). Cela permet de l’appeler avec la
même syntaxe qu’une fonction, tout en pouvant stocker un état
interne.
struct AboveThreshold {
float threshold;
AboveThreshold(float t) : threshold(t) {}
bool operator()(float x) const {
return x > threshold;
}
};Utilisation :
AboveThreshold above10(10.0f);
std::cout << above10(5.0f) << std::endl; // 0 (false)
std::cout << above10(15.0f) << std::endl; // 1 (true)Le foncteur above10 se comporte comme une fonction, mais
il porte son seuil avec lui. C’est la solution
historique au problème de capture de contexte.
Exemple avec un algorithme :
#include <algorithm>
#include <vector>
std::vector<float> v = {3.0f, 12.0f, 7.0f, 15.0f, 1.0f};
int n = std::count_if(v.begin(), v.end(), AboveThreshold(10.0f));
// n = 2 (12.0f et 15.0f)Les fonctions lambda (C++11) sont une syntaxe
compacte pour créer des foncteurs anonymes. Le compilateur génère
automatiquement une classe avec operator() et les captures
nécessaires.
[captures](paramètres) -> type_retour { corps }Le type de retour est souvent omis (déduit automatiquement).
auto square = [](int x) { return x * x; };
std::cout << square(5) << std::endl; // 25Les captures permettent à la lambda d’accéder à des variables locales du scope englobant :
float seuil = 10.0f;
auto above = [seuil](float x) { return x > seuil; }; // capture par copie
auto below = [&seuil](float x) { return x < seuil; }; // capture par référenceRaccourcis :
[=] : capture toutes les variables utilisées par
copie,[&] : capture toutes les variables utilisées par
référence,[=, &x] : tout par copie sauf x par
référence.La lambda :
float seuil = 10.0f;
auto above = [seuil](float x) { return x > seuil; };est équivalente au foncteur :
struct Lambda {
float seuil;
bool operator()(float x) const { return x > seuil; }
};
Lambda above{10.0f};Le compilateur génère cette structure automatiquement. Chaque lambda a un type unique connu du compilateur, ce qui permet des optimisations (inlining) impossibles avec les pointeurs de fonctions.
std::function
: un type uniforme pour tout callableLes pointeurs de fonctions, foncteurs et lambdas ont des
types différents. Pour stocker l’un ou l’autre dans une
même variable, on utilise std::function (défini dans
<functional>) :
#include <functional>
int add(int a, int b) { return a + b; }
struct Multiplier {
int factor;
int operator()(int a, int b) const { return factor * (a + b); }
};
int main() {
std::function<int(int, int)> op;
op = add; // pointeur de fonction
std::cout << op(3, 4) << std::endl; // 7
op = Multiplier{2}; // foncteur
std::cout << op(3, 4) << std::endl; // 14
op = [](int a, int b) { return a - b; }; // lambda
std::cout << op(3, 4) << std::endl; // -1
}std::function<int(int, int)> peut contenir
n’importe quel callable dont la signature est
int(int, int).
std::functionstd::function introduit un surcoût par
rapport à un appel direct : il utilise l’effacement de type (type
erasure) en interne, ce qui implique une indirection (similaire à
un appel virtuel). Pour du code critique en performance, on préfère les
templates :
// Version template : pas d'indirection, la fonction est inlinée
template <typename F>
void apply(int* data, int size, F f) {
for (int i = 0; i < size; ++i)
data[i] = f(data[i]);
}
apply(arr, 4, [](int x) { return x * 2; }); // inliné par le compilateurC’est d’ailleurs l’approche utilisée par les algorithmes de la bibliothèque standard.
// Exemple : callback de rendu
using RenderCallback = std::function<void(float dt)>;
class Engine {
public:
void set_render(RenderCallback cb) { render = cb; }
void frame(float dt) {
if (render) render(dt);
}
private:
RenderCallback render;
};La Standard Template Library (STL) est l’un des piliers du C++ moderne. Elle fournit un ensemble cohérent de conteneurs (structures de données), d’itérateurs (mécanisme d’accès uniforme) et d’algorithmes (opérations génériques), tous paramétrés par des templates.
Un conteneur est un objet qui stocke une collection d’éléments. La STL propose plusieurs familles de conteneurs, chacune adaptée à des usages différents. Le choix du bon conteneur repose sur les opérations dominantes du programme : accès par indice, insertion fréquente, recherche par clé, etc.
Les conteneurs séquentiels stockent les éléments dans un ordre déterminé par l’insertion.
std::vector<T>Le std::vector est un tableau dynamique à mémoire
contiguë. Il offre un accès en \(O(1)\) par indice et une insertion en \(O(1)\) amorti en fin de tableau
(push_back). L’insertion ou la suppression en milieu de
tableau est en \(O(n)\) car elle
nécessite de décaler les éléments suivants.
std::deque<T>Le std::deque (double-ended queue) permet l’insertion et
la suppression en \(O(1)\) aux
deux extrémités (push_front,
push_back, pop_front, pop_back).
Contrairement au std::vector, sa mémoire n’est pas contiguë
: elle est organisée en blocs. L’accès par indice reste en \(O(1)\), mais avec un surcoût dû à
l’indirection entre les blocs.
#include <deque>
std::deque<int> dq;
dq.push_back(1);
dq.push_front(0);
// dq contient {0, 1}Cas d’usage : files de travail, buffers circulaires, situations où l’on ajoute aux deux bouts.
std::list<T>Le std::list est une liste doublement
chaînée. Chaque élément est alloué séparément sur le tas et
contient des pointeurs vers l’élément précédent et suivant. L’insertion
et la suppression à n’importe quelle position sont en \(O(1)\) si l’on dispose déjà d’un itérateur
sur la position.
#include <list>
std::list<int> lst = {1, 2, 3, 4};
auto it = lst.begin();
std::advance(it, 2); // avance de 2 positions
lst.insert(it, 99); // insère 99 avant la position 2
// lst contient {1, 2, 99, 3, 4}En contrepartie :
En pratique, std::vector est presque toujours plus
rapide que std::list, même pour des insertions en milieu de
séquence, grâce à la localité mémoire. On réservera
std::list aux cas où l’on a besoin de stabilité des
itérateurs (un itérateur sur un élément reste valide même après des
insertions/suppressions ailleurs dans la liste).
std::forward_list<T>Version simplement chaînée de std::list. Chaque élément
ne pointe que vers le suivant. Plus économe en mémoire, mais ne permet
de parcourir la liste que dans un seul sens.
Les adaptateurs ne sont pas des conteneurs à part entière : ils encapsulent un conteneur existant et en restreignent l’interface pour garantir un usage spécifique.
std::stack<T>Pile LIFO (Last In, First Out). Par défaut, elle encapsule un
std::deque.
#include <stack>
std::stack<int> s;
s.push(1);
s.push(2);
s.push(3);
int top = s.top(); // 3
s.pop(); // retire 3std::queue<T>File FIFO (First In, First Out). Encapsule un std::deque
par défaut.
#include <queue>
std::queue<int> q;
q.push(1);
q.push(2);
int front = q.front(); // 1
q.pop(); // retire 1std::priority_queue<T>File de priorité : l’élément de plus haute priorité est toujours au
sommet. Par défaut, le plus grand élément est en tête (max-heap).
Encapsule un std::vector.
#include <queue>
std::priority_queue<int> pq;
pq.push(3);
pq.push(1);
pq.push(4);
int top = pq.top(); // 4
pq.pop();Les conteneurs associatifs stockent les éléments selon une clé, et permettent une recherche efficace.
std::set<T> et
std::multiset<T>Un std::set stocke des clés uniques
triées. Il est implémenté par un arbre rouge-noir. L’insertion,
la recherche et la suppression sont en \(O(\log n)\).
#include <set>
std::set<int> s = {3, 1, 4, 1, 5};
// s contient {1, 3, 4, 5} — doublons éliminés, trié
s.insert(2);
bool found = s.count(3) > 0; // true
s.erase(4);std::multiset autorise les doublons.
std::map<K, V>
et std::multimap<K, V>Le std::map associe des clés uniques à
des valeurs, triées par clé. Même complexité que
std::set. std::multimap autorise plusieurs
valeurs pour une même clé.
std::unordered_set<T>
et std::unordered_map<K, V>Variantes basées sur des tables de hachage. Les éléments ne sont pas triés, mais les opérations de recherche, insertion et suppression sont en \(O(1)\) en moyenne (contre \(O(\log n)\) pour les versions ordonnées).
#include <unordered_map>
std::unordered_map<std::string, int> ages;
ages["Alice"] = 25;
ages["Bob"] = 30;
if (ages.find("Alice") != ages.end()) {
int a = ages["Alice"]; // 25
}On préfère les versions unordered_ quand l’ordre n’a pas
d’importance et que la performance de recherche est critique.
| Conteneur | Structure | Accès | Insertion | Recherche | Ordre |
|---|---|---|---|---|---|
vector |
tableau contigu | \(O(1)\) | \(O(1)\) fin, \(O(n)\) milieu | \(O(n)\) | insertion |
deque |
blocs | \(O(1)\) | \(O(1)\) début/fin | \(O(n)\) | insertion |
list |
liste chaînée | \(O(n)\) | \(O(1)\) (avec itérateur) | \(O(n)\) | insertion |
set |
arbre | — | \(O(\log n)\) | \(O(\log n)\) | trié |
map |
arbre | — | \(O(\log n)\) | \(O(\log n)\) | trié par clé |
unordered_set |
table de hachage | — | \(O(1)\) moy. | \(O(1)\) moy. | aucun |
unordered_map |
table de hachage | — | \(O(1)\) moy. | \(O(1)\) moy. | aucun |
Un point critique lors de la manipulation des conteneurs est l’invalidation des itérateurs. Certaines opérations rendent les itérateurs existants invalides :
std::vector : toute insertion qui
provoque une réallocation invalide tous les itérateurs.
Une insertion sans réallocation invalide les itérateurs
après le point d’insertion.std::deque : toute insertion invalide
tous les itérateurs (mais pas les références si
l’insertion est aux extrémités).std::list : les itérateurs ne sont
jamais invalidés par des insertions ou suppressions
(sauf l’itérateur pointant sur l’élément supprimé).std::set, std::map : les
itérateurs ne sont invalidés que pour l’élément supprimé.std::unordered_* : une insertion qui
provoque un rehash invalide tous les itérateurs.Utiliser un itérateur invalidé est un comportement indéfini : le programme peut crasher, produire des résultats faux, ou sembler fonctionner correctement.
// ERREUR classique : suppression pendant l'itération
std::vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 3)
v.erase(it); // it est invalidé ! UB au ++it suivant
}
// Version correcte : erase retourne l'itérateur suivant
for (auto it = v.begin(); it != v.end(); ) {
if (*it == 3)
it = v.erase(it);
else
++it;
}Les itérateurs constituent le lien entre les conteneurs et les algorithmes. Un itérateur est un objet qui généralise la notion de pointeur : il permet de désigner un élément dans un conteneur et d’avancer vers l’élément suivant, sans que le code ait besoin de connaître la structure interne du conteneur.
Tout itérateur fournit au minimum les opérations suivantes :
*it : accéder à l’élément pointé
(déréférencement),++it : avancer à l’élément suivant,it1 != it2 : comparer deux itérateurs.Avec ces trois opérations, on peut parcourir n’importe quel conteneur de manière uniforme :
template <typename Container>
void print_all(Container const& c) {
for (auto it = c.begin(); it != c.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
}Cette fonction fonctionne pour un vector, un
list, un set, un deque, etc.
C’est la boucle range-based for (auto& x : c) qui
utilise ce mécanisme en interne.
begin /
endChaque conteneur fournit :
begin() : itérateur vers le premier
élément,end() : itérateur vers la position après le
dernier élément (sentinelle).L’intervalle est semi-ouvert :
[begin, end). Cette convention simplifie les boucles (la
condition d’arrêt est it != end()) et permet de représenter
un conteneur vide (begin() == end()).
Des versions const existent : cbegin() /
cend() retournent des itérateurs qui interdisent la
modification des éléments.
Pour les conteneurs bidirectionnels (list,
set, map, deque,
vector), des itérateurs inversés sont également disponibles
: rbegin() / rend() parcourent le conteneur à
l’envers.
Tous les itérateurs ne supportent pas les mêmes opérations. La STL définit une hiérarchie de catégories, du plus restrictif au plus puissant :
| Catégorie | Opérations | Exemples de conteneurs |
|---|---|---|
| Input | *it (lecture), ++it, != |
flux d’entrée (istream_iterator) |
| Forward | Input + passages multiples | forward_list, unordered_set |
| Bidirectional | Forward + --it |
list, set, map |
| Random Access | Bidirectional + it + n, it[n],
it1 - it2, < |
vector, deque, array |
Un itérateur de catégorie supérieure supporte toutes les opérations des catégories inférieures.
Conséquences pratiques :
std::vector<int> v = {3, 1, 4, 1, 5};
std::list<int> lst = {3, 1, 4, 1, 5};
// OK : vector a des itérateurs random access
auto it_v = v.begin() + 3;
// ERREUR : list a des itérateurs bidirectional, pas d'opérateur +
// auto it_l = lst.begin() + 3;
// Version correcte pour list :
auto it_l = lst.begin();
std::advance(it_l, 3); // avance de 3 positionsLa fonction std::advance(it, n) (définie dans
<iterator>) fonctionne pour toutes les catégories :
elle effectue n incrémentations pour les itérateurs non
random-access, et un saut direct pour les random-access.
L’en-tête <iterator> fournit des fonctions utiles
:
std::advance(it, n) : avance it de
n positions.std::distance(first, last) : retourne le nombre
d’éléments entre deux itérateurs.std::next(it, n) / std::prev(it, n) :
retourne un nouvel itérateur avancé/reculé de n positions
(sans modifier it).#include <iterator>
std::list<int> lst = {10, 20, 30, 40, 50};
auto it = std::next(lst.begin(), 2); // pointe sur 30
int d = std::distance(lst.begin(), it); // d = 2L’en-tête <algorithm> fournit une vaste collection
de fonctions génériques qui opèrent sur des intervalles
d’itérateurs [begin, end). Ces algorithmes sont
indépendants du conteneur utilisé : ils ne connaissent que les
itérateurs. C’est cette séparation qui rend la STL si puissante et
extensible.
Les algorithmes STL acceptent souvent un prédicat ou une fonction de transformation en paramètre. On utilise pour cela des lambdas, des foncteurs ou des pointeurs de fonctions, tels que présentés dans le chapitre précédent.
#include <algorithm>
#include <vector>
std::vector<int> v = {3, 1, 4, 1, 5, 9};
// Recherche d'une valeur
auto it = std::find(v.begin(), v.end(), 4);
if (it != v.end())
std::cout << "Trouvé à la position " << std::distance(v.begin(), it) << std::endl;
// Recherche avec prédicat
auto it2 = std::find_if(v.begin(), v.end(), [](int x) { return x > 4; });
// it2 pointe sur 5
// Compter les occurrences
int n = std::count(v.begin(), v.end(), 1); // n = 2
// Tester si tous/certains/aucun éléments satisfont une condition
bool all_pos = std::all_of(v.begin(), v.end(), [](int x) { return x > 0; }); // true
bool has_neg = std::any_of(v.begin(), v.end(), [](int x) { return x < 0; }); // falsestd::vector<int> v = {3, 1, 4, 1, 5, 9};
// Tri croissant
std::sort(v.begin(), v.end());
// v = {1, 1, 3, 4, 5, 9}
// Tri avec comparateur personnalisé (décroissant)
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
// v = {9, 5, 4, 3, 1, 1}
// Tri stable (préserve l'ordre relatif des éléments égaux)
std::stable_sort(v.begin(), v.end());std::sort requiert des itérateurs random
access. Il ne fonctionne donc pas directement sur un
std::list (qui fournit sa propre méthode
lst.sort()).
std::vector<int> v = {1, 2, 3, 4, 5};
std::vector<int> result(v.size());
// Appliquer une transformation à chaque élément
std::transform(v.begin(), v.end(), result.begin(),
[](int x) { return x * x; });
// result = {1, 4, 9, 16, 25}
// Appliquer une action sans produire de résultat
std::for_each(v.begin(), v.end(), [](int& x) { x *= 2; });
// v = {2, 4, 6, 8, 10}L’en-tête <numeric> fournit des opérations
d’accumulation :
#include <numeric>
std::vector<int> v = {1, 2, 3, 4, 5};
// Somme
int sum = std::accumulate(v.begin(), v.end(), 0);
// sum = 15
// Produit
int prod = std::accumulate(v.begin(), v.end(), 1, std::multiplies<int>());
// prod = 120
// Accumulation avec lambda
int sum_squares = std::accumulate(v.begin(), v.end(), 0,
[](int acc, int x) { return acc + x * x; });
// sum_squares = 55std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
// Supprimer les éléments égaux à 1
// std::remove ne supprime pas réellement : il déplace les éléments à garder
// au début et retourne un itérateur vers la nouvelle fin
auto new_end = std::remove(v.begin(), v.end(), 1);
v.erase(new_end, v.end());
// v = {3, 4, 5, 9, 2, 6}
// Supprimer selon un prédicat (idiome erase-remove)
v.erase(
std::remove_if(v.begin(), v.end(), [](int x) { return x < 4; }),
v.end()
);
// v = {4, 5, 9, 6}
// Inverser l'ordre
std::reverse(v.begin(), v.end());
// Remplir avec une valeur
std::fill(v.begin(), v.end(), 0);
// Copier
std::vector<int> src = {1, 2, 3};
std::vector<int> dst(3);
std::copy(src.begin(), src.end(), dst.begin());Les conteneurs triés ou les séquences triées peuvent bénéficier d’algorithmes spécifiques :
std::vector<int> a = {1, 2, 3, 4, 5};
std::vector<int> b = {3, 4, 5, 6, 7};
std::vector<int> result;
// Intersection
std::set_intersection(a.begin(), a.end(), b.begin(), b.end(),
std::back_inserter(result));
// result = {3, 4, 5}
// Recherche dichotomique (séquence triée)
bool found = std::binary_search(a.begin(), a.end(), 3); // true
// Bornes
auto lo = std::lower_bound(a.begin(), a.end(), 3); // premier >= 3
auto hi = std::upper_bound(a.begin(), a.end(), 3); // premier > 3std::back_inserter (dans <iterator>)
crée un itérateur d’insertion qui appelle push_back sur le
conteneur cible, ce qui permet aux algorithmes de remplir un conteneur
sans en connaître la taille à l’avance.
Le principe de la STL repose sur la séparation entre conteneurs et algorithmes, reliés par les itérateurs :
Conteneurs ──> Itérateurs ──> Algorithmes
Cette architecture permet de combiner \(M\) conteneurs avec \(N\) algorithmes via une seule interface, au lieu d’écrire \(M \times N\) implémentations.
Pour qu’une classe soit compatible avec les boucles range-based
(for (auto& x : obj)) et les algorithmes STL, il suffit
qu’elle fournisse des méthodes begin() et
end() retournant des itérateurs. Un
itérateur est un objet qui supporte au minimum *it,
++it et !=.
Considérons une grille 2D stockant des valeurs dans un tableau linéaire :
#include <cstddef>
template <typename T>
class Grid {
public:
Grid(int width, int height)
: w(width), h(height), data(new T[width * height]{}) {}
~Grid() { delete[] data; }
T& operator()(int x, int y) { return data[y * w + x]; }
T const& operator()(int x, int y) const { return data[y * w + x]; }
int width() const { return w; }
int height() const { return h; }
int size() const { return w * h; }
// --- Itérateur ---
class iterator {
public:
iterator(T* ptr) : p(ptr) {}
T& operator*() { return *p; }
iterator& operator++() { ++p; return *this; }
bool operator!=(iterator const& other) const { return p != other.p; }
private:
T* p;
};
class const_iterator {
public:
const_iterator(T const* ptr) : p(ptr) {}
T const& operator*() const { return *p; }
const_iterator& operator++() { ++p; return *this; }
bool operator!=(const_iterator const& other) const { return p != other.p; }
private:
T const* p;
};
iterator begin() { return iterator(data); }
iterator end() { return iterator(data + w * h); }
const_iterator begin() const { return const_iterator(data); }
const_iterator end() const { return const_iterator(data + w * h); }
private:
int w, h;
T* data;
};Utilisation :
Grid<float> g(3, 2);
g(0, 0) = 1.0f;
g(1, 0) = 2.0f;
g(2, 1) = 6.0f;
// Boucle range-based
for (float v : g) {
std::cout << v << " ";
}
std::cout << std::endl;
// Compatible avec les algorithmes STL
float sum = std::accumulate(g.begin(), g.end(), 0.0f);
auto it = std::find(g.begin(), g.end(), 6.0f);iterator_traitsL’exemple ci-dessus suffit pour les boucles range-based et de
nombreux algorithmes. Cependant, certains algorithmes STL (comme
std::sort ou std::distance) s’appuient sur des
traits de l’itérateur pour choisir l’implémentation
optimale. On déclare ces traits en définissant des alias dans
l’itérateur ou en spécialisant std::iterator_traits.
#include <iterator>
class iterator {
public:
using iterator_category = std::forward_iterator_tag;
using value_type = T;
using difference_type = std::ptrdiff_t;
using pointer = T*;
using reference = T&;
iterator(T* ptr) : p(ptr) {}
reference operator*() { return *p; }
pointer operator->() { return p; }
iterator& operator++() { ++p; return *this; }
iterator operator++(int) { iterator tmp = *this; ++p; return tmp; }
bool operator==(iterator const& other) const { return p == other.p; }
bool operator!=(iterator const& other) const { return p != other.p; }
private:
T* p;
};Les cinq alias (iterator_category,
value_type, difference_type,
pointer, reference) permettent aux algorithmes
de connaître le type d’itérateur et d’adapter leur comportement. Par
exemple, std::distance utilisera une soustraction directe
pour un random_access_iterator_tag, mais une boucle
d’incrémentations pour un forward_iterator_tag.
Pour un itérateur sur un tableau contigu (comme notre grille), on
peut utiliser std::random_access_iterator_tag et ajouter
les opérations correspondantes (+, -,
[], <) :
class iterator {
public:
using iterator_category = std::random_access_iterator_tag;
using value_type = T;
using difference_type = std::ptrdiff_t;
using pointer = T*;
using reference = T&;
iterator(T* ptr) : p(ptr) {}
reference operator*() { return *p; }
pointer operator->() { return p; }
reference operator[](difference_type n) { return p[n]; }
iterator& operator++() { ++p; return *this; }
iterator operator++(int) { iterator tmp = *this; ++p; return tmp; }
iterator& operator--() { --p; return *this; }
iterator operator--(int) { iterator tmp = *this; --p; return tmp; }
iterator operator+(difference_type n) const { return iterator(p + n); }
iterator operator-(difference_type n) const { return iterator(p - n); }
difference_type operator-(iterator const& other) const { return p - other.p; }
iterator& operator+=(difference_type n) { p += n; return *this; }
iterator& operator-=(difference_type n) { p -= n; return *this; }
bool operator==(iterator const& other) const { return p == other.p; }
bool operator!=(iterator const& other) const { return p != other.p; }
bool operator<(iterator const& other) const { return p < other.p; }
bool operator>(iterator const& other) const { return p > other.p; }
bool operator<=(iterator const& other) const { return p <= other.p; }
bool operator>=(iterator const& other) const { return p >= other.p; }
private:
T* p;
};Avec cet itérateur complet, la classe Grid est
pleinement compatible avec tous les algorithmes STL, y compris
std::sort :
Grid<int> g(4, 3);
// ... remplissage ...
std::sort(g.begin(), g.end());
std::reverse(g.begin(), g.end());
int n = std::count_if(g.begin(), g.end(), [](int x) { return x > 0; });Les itérateurs ne sont pas limités à parcourir de la mémoire brute. On peut concevoir un itérateur qui génère ou transforme des valeurs à la volée.
Exemple : un itérateur qui génère une suite d’entiers (similaire à
range en Python) :
class IntRange {
public:
IntRange(int start, int stop) : start_(start), stop_(stop) {}
class iterator {
public:
using iterator_category = std::forward_iterator_tag;
using value_type = int;
using difference_type = std::ptrdiff_t;
using pointer = int const*;
using reference = int;
iterator(int val) : v(val) {}
int operator*() const { return v; }
iterator& operator++() { ++v; return *this; }
bool operator!=(iterator const& other) const { return v != other.v; }
private:
int v;
};
iterator begin() const { return iterator(start_); }
iterator end() const { return iterator(stop_); }
private:
int start_, stop_;
};Utilisation :
for (int i : IntRange(0, 10)) {
std::cout << i << " ";
}
// Affiche : 0 1 2 3 4 5 6 7 8 9
// Compatible avec les algorithmes
IntRange r(1, 6);
int sum = std::accumulate(r.begin(), r.end(), 0); // 15Cet itérateur ne stocke aucune donnée en mémoire : il calcule chaque
valeur au moment du déréférencement. Ce pattern est utilisé dans les
bibliothèques modernes de ranges (C++20
std::views).
Le parallélisme désigne la capacité d’un programme à exécuter plusieurs tâches simultanément. En C++, cette notion est directement liée aux threads, qui permettent d’exploiter les cœurs multiples des processeurs modernes. Comprendre les threads est essentiel pour écrire des programmes performants, mais aussi sûrs et corrects.
Historiquement, les processeurs n’avaient qu’un seul cœur : les programmes s’exécutaient instruction après instruction, et la seule façon d’accélérer un programme était d’augmenter la fréquence du processeur. Depuis le milieu des années 2000, les fabricants de processeurs ont atteint des limites physiques (dissipation thermique, consommation électrique) qui empêchent d’augmenter indéfiniment la fréquence. La solution a été de multiplier les cœurs sur une même puce : au lieu d’un seul cœur très rapide, on dispose de 4, 8, 16 cœurs ou plus, capables de travailler en parallèle. Mais pour en profiter, il faut que le programme soit explicitement conçu pour répartir son travail sur plusieurs fils d’exécution.
Il est important de distinguer deux notions :
Un programme multithread est toujours concurrent, mais il n’est réellement parallèle que si la machine dispose de suffisamment de cœurs et que le système d’exploitation distribue les threads sur ces cœurs.
Un thread (ou fil d’exécution) est une unité d’exécution à l’intérieur d’un processus. Chaque thread possède son propre compteur d’instructions (il sait où il en est dans le code) et sa propre pile d’exécution (pour les variables locales et les appels de fonctions). En revanche, tous les threads d’un même processus partagent le même espace d’adressage : ils accèdent aux mêmes variables globales, au même tas (heap), et aux mêmes fichiers ouverts.
main jusqu’au
return.Cette architecture — mémoire partagée avec piles séparées — est à la fois la force et la difficulté du multithreading : elle permet une communication très rapide entre threads (puisqu’ils accèdent directement aux mêmes données), mais elle impose de gérer soigneusement les accès concurrents pour éviter les incohérences.
Avant C++11, le langage ne proposait aucun mécanisme standard pour
créer des threads : il fallait recourir à des bibliothèques spécifiques
au système d’exploitation (POSIX threads sur Linux/macOS, Win32 threads
sur Windows), ce qui rendait le code non portable. Depuis
C++11, la bibliothèque standard fournit
std::thread (définie dans <thread>), une
abstraction portable qui encapsule un fil d’exécution du système.
Exemple simple :
#include <iostream>
#include <thread>
void task() {
std::cout << "Hello depuis un thread" << std::endl;
}
int main() {
std::thread t(task); // création du thread
t.join(); // attendre la fin du thread
return 0;
}Le cycle de vie d’un std::thread suit des règles
strictes :
std::thread. Il n’y a pas de méthode
start() : dès que l’objet est créé, le système
d’exploitation lance le fil d’exécution.join() bloque le thread appelant (ici
main) jusqu’à ce que le thread t ait terminé
son exécution. C’est la manière la plus courante d’attendre la fin d’un
thread. Après un join(), le thread est considéré comme
terminé et l’objet std::thread ne représente plus un fil
actif.detach() dissocie le thread de l’objet
std::thread : le fil d’exécution continue en arrière-plan
de manière autonome, et l’objet std::thread ne peut plus
être joint. On utilise detach() pour des tâches “fire and
forget” (par exemple un thread de logging), mais c’est plus risqué car
on perd le contrôle sur la durée de vie du thread.std::thread soit détruit (fin de portée, sortie de
fonction), il faut obligatoirement avoir appelé join() ou
detach(). Si aucun des deux n’a été appelé, le destructeur
provoque std::terminate(), ce qui arrête brutalement tout
le programme. Cette règle empêche les threads “oubliés”.Considérons maintenant deux threads exécutant une tâche visible dans le temps.
#include <iostream>
#include <thread>
#include <chrono>
void task(int id) {
for(int i = 0; i < 5; ++i) {
std::cout << "Thread " << id << " : étape " << i << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main() {
std::thread t1(task, 1);
std::thread t2(task, 2);
t1.join();
t2.join();
return 0;
}(Remarque : std::chrono (dans
<chrono>) fournit des types pour durées et horloges,
p.ex. milliseconds.)
Sortie typique (l’ordre exact peut varier) :
Thread 1 : étape 0
Thread 2 : étape 0
Thread 1 : étape 1
Thread 2 : étape 1
Thread 2 : étape 2
Thread 1 : étape 2
Thread 1 : étape 3
Thread 2 : étape 3
Thread 2 : étape 4
Thread 1 : étape 4
Ce que l’on observe :
sleep_for), l’autre peut
s’exécuter.Ce non-déterminisme est une propriété fondamentale du multithreading : on ne peut jamais présumer de l’ordre d’exécution entre deux threads, sauf si on introduit explicitement des mécanismes de synchronisation.
Lorsqu’on crée un std::thread, on peut passer des
arguments à la fonction exécutée par le thread. La syntaxe générale est
:
std::thread t(fonction, arg1, arg2, arg3, ...);Les arguments sont copiés dans le stockage interne du thread. C’est un choix de conception délibéré : comme le thread s’exécute de manière asynchrone, la variable originale pourrait être détruite ou modifiée avant que le thread n’y accède. La copie garantit que le thread possède ses propres données, indépendantes du thread appelant.
void print(int x) {
std::cout << x << std::endl;
}
std::thread t(print, 42);
t.join();Si la fonction attend une référence (par exemple
pour modifier une variable du thread appelant), la copie par défaut pose
problème : le thread modifierait sa copie locale, pas la variable
originale. Pour forcer un vrai passage par référence, on utilise
std::ref() (défini dans
<functional>) qui crée un wrapper indiquant
explicitement qu’on veut transmettre une référence :
#include <functional>
void increment(int& x) {
x++;
}
int main() {
int a = 5;
std::thread t(increment, std::ref(a));
t.join();
// a vaut maintenant 6
}L’utilisation de std::ref est un acte volontaire : elle
signale au lecteur du code que le thread va accéder à une variable
partagée, ce qui impose de s’assurer que l’accès est correctement
synchronisé (ou, comme ici, que le thread principal attend la fin du
thread avant de lire la variable).
En pratique, on lance souvent un nombre variable de threads, que l’on
stocke dans un conteneur pour les joindre ensuite. Le pattern typique
consiste à utiliser un std::vector<std::thread> :
#include <thread>
#include <vector>
void work(int id) {
// calcul indépendant
}
int main() {
std::vector<std::thread> threads;
for(int i = 0; i < 4; ++i)
threads.emplace_back(work, i);
for(auto& t : threads)
t.join();
}Chaque thread peut être exécuté sur un cœur physique différent —
c’est le système d’exploitation qui décide de l’affectation. La fonction
std::thread::hardware_concurrency() retourne le nombre de
threads matériels disponibles (typiquement le nombre de cœurs, ou le
double si le processeur supporte l’hyper-threading). C’est un bon
indicateur pour choisir combien de threads lancer :
unsigned int n = std::thread::hardware_concurrency();
// n vaut typiquement 4, 8, 16... selon la machineUn cas d’usage fréquent est le découpage de données (data parallelism) : on divise un grand tableau en portions égales, et chaque thread traite sa portion. Par exemple, pour appliquer une transformation à un tableau de 1 million d’éléments, on peut lancer 4 threads traitant chacun 250 000 éléments. Comme chaque thread travaille sur une zone mémoire distincte, il n’y a pas besoin de synchronisation.
Le partage de mémoire entre threads est à double tranchant. D’un côté, il permet une communication très efficace (pas besoin de copier des données entre processus). De l’autre, il introduit un problème fondamental : les conditions de course (race conditions).
Une condition de course survient lorsque deux threads ou plus accèdent simultanément à la même donnée, et qu’au moins un des accès est une écriture. Le résultat dépend alors de l’ordre exact d’exécution des instructions, qui est imprévisible.
Exemple dangereux :
int counter = 0;
void increment() {
counter++; // non atomique
}L’instruction counter++ semble élémentaire, mais elle se
décompose en réalité en trois opérations au niveau du
processeur :
counter
depuis la mémoire dans un registre.Si deux threads exécutent ces trois étapes en même temps, exemple de scénario problématique :
Thread A: lire counter (= 0)
Thread B: lire counter (= 0) ← lit AVANT que A n'ait écrit
Thread A: incrémenter → 1
Thread A: écrire counter = 1
Thread B: incrémenter → 1 ← calcule à partir de l'ancienne valeur
Thread B: écrire counter = 1 ← écrase le résultat de A
Résultat : counter vaut 1 au lieu de 2. Les deux
incrémentations ont eu lieu, mais l’une a été “perdue”. Ce type de bug
est particulièrement insidieux car il ne se manifeste pas à chaque
exécution — le programme peut fonctionner correctement 99 fois et
échouer à la 100e, selon les aléas de l’ordonnancement.
Pour résoudre les conditions de course, il faut garantir qu’un seul thread à la fois accède aux données partagées. La zone de code concernée s’appelle une section critique : c’est une portion de code qui ne doit être exécutée que par un seul thread à la fois.
Le mécanisme de base pour protéger une section critique est le mutex (mutual exclusion). Un mutex est un verrou que les threads peuvent acquérir ou relâcher :
En C++, on utilise std::mutex (défini dans
<mutex>) :
#include <mutex>
int counter = 0;
std::mutex m;
void increment() {
std::lock_guard<std::mutex> lock(m);
counter++;
}std::lock_guard est un wrapper RAII qui verrouille le
mutex à sa construction et le déverrouille automatiquement à sa
destruction (fin du bloc {}). Cela garantit que le mutex
est toujours relâché, même si une exception est levée
dans la section critique. C’est la manière recommandée d’utiliser un
mutex — on n’appelle jamais m.lock() /
m.unlock() manuellement, car un oubli de
unlock() bloquerait tous les autres threads
indéfiniment.
Un piège classique du multithreading est le deadlock (interblocage) : deux threads attendent chacun un mutex détenu par l’autre, et aucun ne peut progresser. Cela se produit typiquement quand un programme utilise plusieurs mutex et que les threads les acquièrent dans un ordre différent. Pour éviter les deadlocks, une règle simple est de toujours acquérir les mutex dans le même ordre dans tout le programme.
Les mutex sont puissants mais introduisent un surcoût : chaque verrouillage/déverrouillage implique un appel au système d’exploitation. Pour des opérations simples sur une seule variable (incrémenter un compteur, lire/écrire un flag), ce surcoût est disproportionné.
C++ propose une alternative légère : les variables
atomiques (std::atomic, défini dans
<atomic>). Une variable atomique garantit que toute
opération sur elle (lecture, écriture, incrémentation) est
indivisible : elle s’exécute entièrement sans qu’un
autre thread puisse l’interrompre. Le processeur fournit des
instructions matérielles spéciales (comme lock cmpxchg sur
x86) pour réaliser ces opérations en une seule étape.
#include <atomic>
std::atomic<int> counter(0);
void increment() {
counter++; // opération atomique, pas besoin de mutex
}Avec std::atomic<int>, l’opération
counter++ se traduit en une seule instruction atomique du
processeur qui effectue la lecture, l’incrémentation et l’écriture de
manière indivisible. Aucun autre thread ne peut observer un état
intermédiaire.
Les variables atomiques sont plus rapides qu’un mutex pour des opérations élémentaires (compteurs, flags booléens, indices partagés), car elles évitent le coût des appels système. En revanche, elles sont inadaptées aux opérations composées : si une section critique implique la modification de plusieurs variables qui doivent rester cohérentes entre elles, un mutex reste nécessaire.
Le multithreading n’est pas gratuit. Chaque thread a un coût incompressible :
Il est aussi important de comprendre que la partie séquentielle d’un programme limite le gain maximal du parallélisme. Si 20% du temps d’exécution est incompressiblement séquentiel, même avec une infinité de cœurs, le programme ne pourra jamais aller plus de 5× plus vite (loi d’Amdahl). Autrement dit, paralléliser un programme qui passe 90% de son temps dans une section protégée par un mutex n’apportera quasiment rien.
En pratique, pour tirer parti du parallélisme :
std::thread::hardware_concurrency()).
Au-delà, les threads se disputent les cœurs et le surcoût de commutation
annule les gains.La programmation générique permet d’écrire du code indépendant des types, tout en conservant les performances du C++ compilé. En C++, ce paradigme repose principalement sur les templates, qui permettent de définir des fonctions et des classes paramétrées par des types (ou des valeurs). Les templates sont omniprésents dans la bibliothèque standard (STL) et constituent un outil fondamental pour écrire du code réutilisable, expressif et efficace.
Pour comprendre pourquoi les templates existent, considérons un problème concret. Imaginons qu’on veuille écrire une fonction qui additionne deux valeurs. Sans templates, on serait obligé d’écrire une version pour chaque type :
int add(int a, int b) { return a + b; }
float add(float a, float b) { return a + b; }
double add(double a, double b) { return a + b; }Ces trois fonctions ont exactement la même logique — seul le type change. Cette duplication de code est une source de bugs (si on corrige une version mais pas les autres) et alourdit la maintenance. Les templates résolvent ce problème en permettant d’écrire la logique une seule fois, de manière indépendante du type.
Un template est un modèle de code qui n’est pas directement compilé tel quel. Il définit un patron que le compilateur utilise pour générer automatiquement une version spécialisée du code à chaque fois qu’un nouveau type est utilisé. On peut voir le template comme un “moule” : le moule lui-même ne produit rien, mais il permet de fabriquer autant de pièces que nécessaire, chacune adaptée à un type particulier.
template <typename T>
T add(T a, T b) {
return a + b;
}La syntaxe template <typename T> introduit un
paramètre de type nommé T. Ce
T est un placeholder : il sera remplacé par un
type concret au moment de l’utilisation. Le mot-clé
typename indique que T représente un type (on
peut aussi écrire class à la place — les deux sont
équivalents dans ce contexte, mais typename est plus
explicite).
Utilisation :
int a = add(2, 3); // T = int
float b = add(1.5f, 2.5f); // T = floatLorsque le compilateur rencontre add(2, 3), il déduit
que T = int et génère une fonction
int add(int a, int b). Pour add(1.5f, 2.5f),
il génère float add(float a, float b). Chaque version
générée est du code natif optimisé, sans aucun surcoût à
l’exécution par rapport à une fonction écrite manuellement pour
ce type — contrairement au polymorphisme dynamique (fonctions
virtuelles) qui introduit une indirection à l’exécution.
Les templates de fonctions permettent d’écrire des algorithmes génériques sans dupliquer le code. Le compilateur se charge de vérifier que le type utilisé supporte les opérations requises par le template.
template <typename T>
T maximum(T a, T b) {
return (a > b) ? a : b;
}Cette fonction fonctionne pour tout type supportant l’opérateur
> :
maximum(3, 5); // int
maximum(2.0f, 1.5f); // floatSi le type ne supporte pas l’opérateur requis, l’erreur est détectée
à la compilation. Par exemple, appeler
maximum avec une structure qui n’a pas d’opérateur
> provoquera une erreur du compilateur au moment de
l’instanciation du template — et non à l’exécution. C’est une propriété
importante : les erreurs liées aux templates sont des erreurs de
compilation, jamais des erreurs à l’exécution.
Les templates peuvent aussi être utilisés pour définir des
classes (ou struct)
génériques. Le principe est le même que pour les
fonctions : on paramètre la classe par un ou plusieurs types, et le
compilateur génère une version concrète pour chaque combinaison de types
utilisée.
template <typename T>
struct Box {
T value;
explicit Box(T v) : value(v) {}
};Utilisation :
Box<int> a(3);
Box<float> b(2.5f);Contrairement aux fonctions templates (où le compilateur déduit
souvent T automatiquement à partir des arguments), les
classes templates exigent en général de spécifier explicitement
le type entre chevrons <>. Ici,
Box<int> et Box<float> sont
deux types complètement distincts aux yeux du
compilateur : ils n’ont aucune relation d’héritage entre eux, et une
variable de type Box<int> ne peut pas être affectée à
une variable de type Box<float>.
C’est exactement le mécanisme qui sous-tend les conteneurs de la
bibliothèque standard : std::vector<int>,
std::vector<std::string>,
std::map<std::string, double> sont tous des
instanciations de templates de classes.
En informatique graphique, les templates sont très utilisés pour :
float,
double).Exemple de vecteur générique :
template <typename T>
struct vec3 {
T x, y, z;
vec3(T x_, T y_, T z_) : x(x_), y(y_), z(z_) {}
T norm2() const {
return x*x + y*y + z*z;
}
};Utilisation :
vec3<float> vf(1.0f, 2.0f, 3.0f);
vec3<double> vd(1.0, 2.0, 3.0);Les paramètres d’un template ne sont pas limités aux types. Un template peut aussi prendre des valeurs constantes (entières, booléennes, pointeurs, etc.) connues à la compilation. Ces paramètres non typés permettent d’encoder des informations comme une dimension, une taille de buffer, ou un flag de configuration directement dans le type, sans coût à l’exécution.
template <typename T, int N>
struct Array {
T data[N];
T& operator[](int i) { return data[i]; }
T const& operator[](int i) const { return data[i]; }
};Utilisation :
Array<float, 3> v; // taille connue à la compilationIci, N n’est pas un argument passé au constructeur —
c’est un paramètre du type lui-même.
Array<float, 3> et Array<float, 4>
sont des types distincts, et la taille du tableau interne
data est fixée à la compilation. Le compilateur peut ainsi
allouer exactement la bonne quantité de mémoire sur la pile, sans
allocation dynamique. C’est exactement le principe derrière
std::array<T, N> de la bibliothèque standard.
Un template définit un comportement général, mais il arrive que ce comportement ne soit pas adapté pour certains types spécifiques. La spécialisation permet de fournir une implémentation alternative pour un type donné, sans modifier le template générique. Le compilateur choisit automatiquement la version la plus spécifique disponible.
template <typename T>
struct Printer {
static void print(T const& v) {
std::cout << v << std::endl;
}
};
// spécialisation pour bool
template <>
struct Printer<bool> {
static void print(bool v) {
std::cout << (v ? "true" : "false") << std::endl;
}
};Quand on appelle Printer<int>::print(5), le
compilateur utilise la version générique. Quand on appelle
Printer<bool>::print(true), il utilise la
spécialisation. Ce choix est entièrement résolu à la compilation. La
spécialisation est un mécanisme puissant qui sera détaillé plus loin
dans ce chapitre.
La compilation des templates en C++ obéit à des règles spécifiques, différentes de celles du code classique. Comprendre ces principes est essentiel pour interpréter les messages d’erreur du compilateur et organiser correctement son code.
Les templates reposent sur un principe appelé duck typing statique.
Le principe est le suivant :
Un type est valide s’il fournit toutes les opérations utilisées dans le template.
Par exemple :
template <typename T>
T square(T x) {
return x * x;
}Ce template n’impose aucune contrainte explicite sur
T. Cependant, lors de l’instanciation, le compilateur exige
que le type utilisé possède l’opérateur *.
square(3); // OK : int supporte *
square(2.5f); // OK : float supporte *En revanche :
struct A {};
square(A{}); // ERREUR de compilationL’erreur apparaît au moment où le template est instancié, et non lors de sa définition. C’est une caractéristique clé des templates :
Ce mécanisme explique pourquoi les erreurs liées aux templates peuvent être longues et complexes : le compilateur tente d’instancier le code avec un type donné et échoue lorsqu’une opération requise n’existe pas.
Un template n’est pas compilé tant qu’il n’est pas utilisé. La compilation réelle se fait lors de l’instanciation, c’est-à-dire lorsque le compilateur rencontre une utilisation concrète :
add<int>(2, 3);
add<float>(1.5f, 2.5f);Chaque instanciation génère :
Ainsi :
Box<int>
Box<float>sont deux types distincts, sans relation d’héritage entre eux.
Pour que le compilateur puisse instancier un template, il doit avoir accès à l’implémentation complète du template au moment de la compilation.
Cela a une conséquence majeure sur l’organisation des fichiers.
.hpp)Contrairement aux fonctions et classes classiques, le corps des templates doit être visible partout où ils sont utilisés. C’est pourquoi :
.hpp),.hpp / .cpp.Exemple correct :
// vec.hpp
#pragma once
template <typename T>
T add(T a, T b) {
return a + b;
}// main.cpp
#include "vec.hpp"
int main() {
int a = add(2, 3);
}Si le corps du template était placé dans un .cpp, le
compilateur ne pourrait pas générer les versions spécialisées, car
l’implémentation ne serait pas visible au moment de l’instanciation.
Dans un code classique :
.o) à partir
d’un .cpp,Avec les templates :
Le compilateur ne peut donc pas produire à l’avance une version générique unique du template. Il doit voir à la fois :
Il existe des techniques avancées (instanciation explicite) permettant de séparer partiellement l’implémentation, mais elles restent complexes, en pratique, la règle simple est :
Tout template doit être entièrement défini dans un fichier d’en-tête.
La méta-programmation statique désigne l’ensemble
des techniques permettant d’effectuer des calculs au moment de
la compilation, avant même l’exécution du programme. En C++,
les templates et les expressions constexpr permettent de
déplacer une partie de la logique du programme vers le compilateur. Le
résultat est un code plus rapide à l’exécution, car
certaines décisions et certains calculs sont déjà résolus.
L’idée centrale est la suivante :
utiliser le compilateur comme un moteur de calcul.
Les valeurs produites par la méta-programmation :
Les paramètres templates non typés (entiers) sont le premier outil de méta-programmation.
template <int N>
int static_square()
{
return N * N;
}Utilisation :
int main()
{
const int a = static_square<5>(); // évalué à la compilation
float buffer[static_square<3>()]; // taille connue statiquement
std::cout << a << std::endl;
std::cout << sizeof(buffer) / sizeof(float) << std::endl;
}Ici :
static_square<5>() est calculé par le
compilateur,constexpr
: calculs évalués par le compilateurDepuis C++11, le mot-clé constexpr permet de demander
explicitement une évaluation à la compilation, si les
arguments sont constants.
constexpr int square(int N)
{
return N * N;
}Le compilateur :
Comparaison avec une fonction classique :
int runtime_square(int N)
{
return N * N;
}Utilisation dans un paramètre template :
template <int N>
void print_value()
{
std::cout << N << std::endl;
}
int main()
{
print_value<square(5)>(); // OK : expression constante
// print_value<runtime_square(5)>(); // ERREUR : non constante
}Les templates et constexpr permettent d’écrire des
calculs récursifs évalués à la compilation.
Exemple : calcul du factoriel.
constexpr int factorial(int N)
{
return (N <= 1) ? 1 : N * factorial(N - 1);
}Utilisation comme paramètre template :
template <typename T, int N>
struct vecN
{
T data[N];
};
int main()
{
vecN<float, factorial(4)> v;
for (int k = 0; k < factorial(4); ++k)
v.data[k] = static_cast<float>(k);
}Le calcul de 4! est effectué entièrement à la
compilation.
Avant constexpr, la méta-programmation reposait
exclusivement sur des templates récursifs.
template <int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr int value = 1;
};Utilisation :
int size = Factorial<5>::value; // évalué à la compilationCette technique est plus complexe et moins lisible, mais elle est importante historiquement et encore présente dans certaines bibliothèques.
La méta-programmation statique est utilisée pour :
if constexpr en
C++17),Exemple avec if constexpr :
template <typename T>
void process(T v)
{
if constexpr (std::is_integral_v<T>)
std::cout << "Entier" << std::endl;
else
std::cout << "Non entier" << std::endl;
}
Note: `std::is_integral_v` est fourni par l'en-tête `<type_traits>`.La branche non pertinente est supprimée à la compilation.
L’un des objectifs majeurs de la programmation générique est de rendre le code à la fois générique et lisible. En C++, le compilateur est capable de déduire automatiquement les paramètres template dans de nombreux cas, à partir des arguments fournis lors de l’appel. Comprendre quand cette déduction fonctionne — et quand elle échoue — est essentiel pour écrire des interfaces génériques efficaces.
Lorsqu’un template est utilisé sans préciser explicitement ses paramètres, le compilateur tente de les déduire à partir des types des arguments.
template <typename T>
T add(T a, T b)
{
return a + b;
}Utilisation :
int a = add(2, 3); // T déduit comme int
float b = add(1.2f, 3.4f); // T déduit comme floatIci, le compilateur déduit T automatiquement à partir
des arguments passés à la fonction.
La déduction de types fonctionne uniquement à partir des paramètres de la fonction. Elle ne fonctionne pas à partir du type de retour.
template <typename T>
T identity();Ce template ne peut pas être appelé sans préciser
T, car le compilateur n’a aucune information pour le
déduire.
// identity(); // ERREUR
identity<int>(); // OKConsidérons une fonction générique de produit scalaire :
template <typename TYPE_INPUT, typename TYPE_OUTPUT, int SIZE>
TYPE_OUTPUT dot(TYPE_INPUT const& a, TYPE_INPUT const& b)
{
TYPE_OUTPUT val = 0;
for (int k = 0; k < SIZE; ++k)
val += a[k] * b[k];
return val;
}Utilisation :
vecN<float,3> v0, v1;
// Appel lourd et peu lisible
float p = dot<vecN<float,3>, float, 3>(v0, v1);Dans ce cas :
TYPE_INPUT, TYPE_OUTPUT et
SIZE ne peuvent pas être déduits
automatiquement,La déduction échoue car :
TYPE_OUTPUT n’apparaît que dans le type de
retour,SIZE n’apparaît que comme paramètre
template, pas dans les arguments de la fonction.Le compilateur ne peut déduire un paramètre template que s’il est directement lié aux types des arguments.
Une solution consiste à exposer explicitement les paramètres templates dans la classe générique.
template <typename TYPE, int SIZE>
class vecN
{
public:
using value_type = TYPE;
static constexpr int size() { return SIZE; }
TYPE& operator[](int index);
TYPE const& operator[](int index) const;
private:
TYPE data[SIZE];
};On peut alors écrire une fonction bien plus lisible :
template <typename V>
typename V::value_type dot(V const& a, V const& b)
{
typename V::value_type val = 0;
for (int k = 0; k < V::size(); ++k)
val += a[k] * b[k];
return val;
}Utilisation :
float p = dot(v0, v1); // types et taille déduits automatiquementIci :
V est déduit comme
vecN<float,3>,V::value_type,V::size().typenameLorsqu’un type dépend d’un paramètre template, il doit être précédé
de typename pour indiquer au compilateur qu’il s’agit bien
d’un type.
typename V::value_typeSans typename, le compilateur ne peut pas savoir si
value_type est un type ou une valeur statique.
Les templates peuvent aussi utiliser des paramètres par défaut pour réduire la verbosité :
template <typename T, int N = 3>
struct vecN;Ce mécanisme permet de simplifier certaines utilisations, mais ne remplace pas une bonne conception des interfaces.
auto et
C++17+Depuis C++17, auto peut être utilisé pour déduire le
type de retour d’une fonction template :
template <typename V>
auto norm2(V const& v)
{
auto val = typename V::value_type{};
for (int k = 0; k < V::size(); ++k)
val += v[k] * v[k];
return val;
}Cela améliore la lisibilité tout en conservant la généricité.
La spécialisation des templates permet d’adapter le comportement d’un template générique à un cas particulier, sans modifier l’implémentation générale. Elle est utilisée lorsque, pour un type ou un paramètre précis, le comportement par défaut n’est pas adapté, inefficace ou incorrect.
La spécialisation est un mécanisme résolu à la compilation, et fait partie intégrante de la programmation générique en C++.
On commence par définir un template générique (cas général), puis on fournit une implémentation spécialisée pour un type ou une valeur donnée.
template <typename T>
struct Printer
{
static void print(T const& v)
{
std::cout << v << std::endl;
}
};Ce template fonctionne pour tout type compatible avec
operator<<.
Une spécialisation complète remplace entièrement l’implémentation du template pour un type précis.
template <>
struct Printer<bool>
{
static void print(bool v)
{
std::cout << (v ? "true" : "false") << std::endl;
}
};Utilisation :
Printer<int>::print(5); // utilise la version générique
Printer<bool>::print(true); // utilise la spécialisationLe compilateur choisit automatiquement la version la plus spécifique disponible.
Les templates de fonctions peuvent également être spécialisés, mais leur usage est plus délicat.
template <typename T>
void display(T v)
{
std::cout << v << std::endl;
}
template <>
void display<bool>(bool v)
{
std::cout << (v ? "true" : "false") << std::endl;
}Ici aussi, la version spécialisée est utilisée lorsque
T = bool.
La spécialisation partielle permet de spécialiser un template pour une famille de types, mais elle n’est autorisée que pour les templates de classes, pas pour les fonctions.
Exemple : spécialisation selon un paramètre entier.
template <typename T, int N>
struct Array
{
T data[N];
};Spécialisation partielle pour N = 0 :
template <typename T>
struct Array<T, 0>
{
// tableau vide
};Ici, tous les types Array<T,0> utilisent cette
version spécifique.
Autre exemple classique :
template <typename T>
struct is_pointer
{
static constexpr bool value = false;
};
template <typename T>
struct is_pointer<T*>
{
static constexpr bool value = true;
};Utilisation :
is_pointer<int>::value; // false
is_pointer<int*>::value; // trueCe type de spécialisation est largement utilisé dans la STL
(std::is_pointer, std::is_integral, etc.).
La spécialisation totale consiste à fournir une implémentation spécifique pour une combinaison entièrement fixée des paramètres template (types et/ou valeurs). Pour cette combinaison précise, le template générique n’est pas utilisé du tout : la spécialisation le remplace intégralement.
Dans le contexte des vecteurs génériques, cela permet par exemple :
On définit d’abord un template générique pour un vecteur de taille arbitraire connue à la compilation.
template <typename T, int N>
struct vec
{
T data[N];
T& operator[](int i) { return data[i]; }
T const& operator[](int i) const { return data[i]; }
};Ce template fonctionne pour tout type T
et toute taille N.
Supposons que l’on souhaite un traitement particulier pour les vecteurs 2D, par exemple :
x et y,On définit alors une spécialisation totale :
template <typename T>
struct vec<T, 2>
{
T x, y;
vec() : x(0), y(0) {}
vec(T x_, T y_) : x(x_), y(y_) {}
T& operator[](int i)
{
return (i == 0) ? x : y;
}
T const& operator[](int i) const
{
return (i == 0) ? x : y;
}
};Ici :
vec<T,2> est un type complètement
différent de vec<T,N>,data[N] n’existe plus,N = 2.vec<float, 3> v3;
v3[0] = 1.0f;
v3[1] = 2.0f;
v3[2] = 3.0f;
vec<float, 2> v2(1.0f, 4.0f);
std::cout << v2[0] << " " << v2[1] << std::endl;vec<float,3> utilise le template
générique,vec<float,2> utilise la spécialisation
totale.Le choix est fait à la compilation, sans aucun test à l’exécution.
Il est aussi possible de spécialiser pour un type et une taille précis.
template <>
struct vec<float, 3>
{
float x, y, z;
vec() : x(0.f), y(0.f), z(0.f) {}
vec(float x_, float y_, float z_) : x(x_), y(y_), z(z_) {}
float norm2() const
{
return x*x + y*y + z*z;
}
};Utilisation :
vec<float,3> v(1.f, 2.f, 3.f);
std::cout << v.norm2() << std::endl;Ici :
vec<float,3>,vec<double,3>,
vec<float,4>, etc.) utilisent le template
générique.Spécialisation totale Tous les paramètres
template sont fixés (vec<float,3>). → un cas unique,
comportement entièrement redéfini.
Spécialisation partielle Seule une partie des
paramètres est fixée (vec<T,2>). → une famille de
types partageant un comportement spécifique.
Il est fréquent de confondre surcharge (overloading) et spécialisation de templates, mais ce sont deux mécanismes distincts qui interviennent à des moments différents de la compilation. Comprendre leur ordre de priorité est essentiel pour éviter des comportements surprenants.
Le principe fondamental :
La surcharge est résolue avant la spécialisation de templates.
Autrement dit, le compilateur choisit d’abord quelle fonction appeler, puis seulement quelle version de template instancier.
Lorsque plusieurs fonctions portent le même nom, le compilateur commence par appliquer les règles classiques de surcharge :
Exemple :
void display(int x)
{
std::cout << "fonction normale int\n";
}
template <typename T>
void display(T x)
{
std::cout << "template generique\n";
}Appel :
display(3);Résultat :
fonction normale int
Une fonction non template est toujours prioritaire sur une fonction template si elle correspond exactement.
Si aucune fonction non-template ne correspond, le compilateur considère les fonctions templates et tente d’en déduire les paramètres.
template <typename T>
void display(T x)
{
std::cout << "template generique\n";
}
display(3.5); // T = doubleIci, le template est sélectionné car aucune fonction classique ne correspond.
Une fois qu’un template a été choisi, le compilateur cherche s’il existe une spécialisation plus spécifique pour les paramètres déduits.
template <typename T>
void display(T x)
{
std::cout << "template generique\n";
}
template <>
void display<bool>(bool x)
{
std::cout << "specialisation bool\n";
}Appels :
display(5); // template générique
display(true); // spécialisation boolRésultat :
template generique
specialisation bool
La spécialisation ne participe pas à la surcharge. Elle est sélectionnée après que le template générique a été choisi.
Considérons maintenant :
template <typename T>
void display(T x)
{
std::cout << "template generique\n";
}
template <>
void display<int>(int x)
{
std::cout << "specialisation int\n";
}
void display(int x)
{
std::cout << "fonction normale int\n";
}Appel :
display(3);Résultat :
fonction normale int
Explication :
display(int) → prioritaire,Une spécialisation ne peut jamais battre une surcharge non-template.
Parce que :
C++ impose donc une hiérarchie stricte.
Lors d’un appel de fonction :
Sélection des fonctions candidates (nom, portée).
Résolution de surcharge :
Si un template est choisi :
Instanciation du code correspondant.
La surcharge choisit la fonction. La spécialisation choisit l’implémentation du template.
En pratique, on utilisera la surcharge pour proposer des interfaces différentes, et la spécialisation pour adapter un comportement interne à un template. Mieux vaut éviter de mélanger les deux sur un même nom sans raison claire.
typedef et
using)Les alias de types permettent de donner un nom plus lisible ou plus expressif à un type, souvent complexe. Ils jouent un rôle central en programmation générique, car ils facilitent la déduction de types, l’écriture de fonctions génériques et la lisibilité des interfaces.
En C++, il existe deux mécanismes équivalents :
typedef (historique),using (moderne, recommandé).typedef (forme historique)typedef unsigned int uint;Ce mécanisme fonctionne, mais devient rapidement peu lisible avec des types complexes, notamment en présence de templates.
using
(forme moderne)Depuis C++11, on préfère utiliser using, plus clair et
plus puissant.
using uint = unsigned int;Cette syntaxe est équivalente à typedef, mais beaucoup
plus lisible, surtout avec des templates.
Les alias sont très souvent utilisés à l’intérieur des classes templates pour exposer leurs paramètres internes.
Exemple avec un vecteur générique :
template <typename T, int N>
class vec
{
public:
using value_type = T;
static constexpr int size() { return N; }
T& operator[](int i) { return data[i]; }
T const& operator[](int i) const { return data[i]; }
private:
T data[N];
};Ici :
vec<T,N>::value_type donne accès au type
stocké,vec<T,N>::size() donne accès à la taille connue à
la compilation.Ces alias rendent la classe auto-descriptive et facilitent son utilisation dans du code générique.
Grâce aux alias, on peut écrire des fonctions génériques sans connaître explicitement les paramètres template.
template <typename V>
typename V::value_type sum(V const& v)
{
typename V::value_type s = 0;
for (int i = 0; i < V::size(); ++i)
s += v[i];
return s;
}Utilisation :
vec<float,3> v;
v[0] = 1.0f; v[1] = 2.0f; v[2] = 3.0f;
float s = sum(v);Ici :
value_type,typename)Lorsque l’on accède à un alias dépendant d’un paramètre template, il
est nécessaire d’utiliser le mot-clé typename pour indiquer
qu’il s’agit bien d’un type.
typename V::value_typeSans typename, le compilateur ne peut pas savoir si
value_type est un type ou une valeur statique.
Les alias eux-mêmes peuvent être templates, ce qui permet de simplifier des types très complexes.
template <typename T>
using vec3 = vec<T, 3>;Utilisation :
vec3<float> a;
vec3<double> b;Ici :
vec3<float> est équivalent à
vec<float,3>,Les alias sont largement utilisés dans la STL :
value_type,iterator,reference,const_reference.Respecter ces conventions permet de rendre ses classes compatibles avec les algorithmes génériques.
Exemple :
template <typename Container>
void print_container(Container const& c)
{
for (typename Container::value_type const& v : c)
std::cout << v << " ";
}Ce chapitre présente les principes méthodologiques fondamentaux permettant de produire du code C++ :
tout en respectant les contraintes de performance et de bas niveau propres au langage.
Ces principes s’appliquent aussi bien à de petits programmes qu’à des projets complexes (simulation, moteur graphique, calcul parallèle).
La qualité de code ne se mesure pas à l’élégance perçue, mais à des critères pratiques :
Notons que lorsque l’on travaille à plusieurs, la lisibilité du code doit être la priorité. Un code lisible :
Dans la plupart des cas, il faut privilégier la lisibilité et la simplicité plutôt que des optimisations micro-performantes prématurées. L’efficacité peut être recherchée ensuite, de manière ciblée et mesurée, quand un goulot de performance est avéré.
Bonnes pratiques pour la lisibilité : noms explicites, fonctions courtes, commentaires quand le code n’est pas auto-documenté, formatage cohérent, et revues de code systématiques.
Un code simple est plus fiable qu’un code complexe. La complexité est la principale source de bugs dans les logiciels : chaque niveau d’indirection, chaque abstraction supplémentaire, chaque cas particulier ajouté augmente le nombre de chemins d’exécution possibles et rend le raisonnement plus difficile.
En C++, la tentation de la complexité est particulièrement forte : le langage offre des templates variadiques, de la méta-programmation, des SFINAE, des concepts, de l’héritage multiple… Ces outils sont puissants, mais leur utilisation prématurée produit souvent du code que seul l’auteur comprend — et parfois même plus après quelques semaines.
Concrètement, KISS se traduit par :
Exemple (KISS) :
// Version condensée et moins lisible : logique imbriquée, calcul d'index
// difficile à suivre, tout est condensé sur quelques lignes.
int count_neighbors_ugly(const std::vector<int>& grid, size_t w, size_t h,
size_t x, size_t y)
{
int c = 0;
// balayer un rectangle 3x3 centré sur (x,y) en jouant sur les bornes
size_t start = (y ? y - 1 : 0) * w + (x ? x - 1 : 0);
size_t end_y = (y + 1 < h ? y + 1 : h - 1);
size_t end_x = (x + 1 < w ? x + 1 : w - 1);
for (size_t idx = start;; ++idx) {
size_t cx = idx % w;
size_t cy = idx / w;
if (!(cx == x && cy == y)) c += grid[idx];
if (cy == end_y && cx == end_x) break; // logique subtle
}
return c;
}
// Version claire et simple : fonctions auxiliaires et boucles explicites
inline bool in_bounds(size_t x, size_t y, size_t w, size_t h) { return x < w && y < h; }
inline int at(const std::vector<int>& g, size_t w, size_t x, size_t y) { return g[y * w + x]; }
int count_neighbors(const std::vector<int>& grid, size_t w, size_t h,
size_t x, size_t y)
{
int c = 0;
size_t y0 = (y > 0) ? y - 1 : 0;
size_t y1 = (y + 1 < h) ? y + 1 : h - 1;
size_t x0 = (x > 0) ? x - 1 : 0;
size_t x1 = (x + 1 < w) ? x + 1 : w - 1;
for (size_t yy = y0; yy <= y1; ++yy) {
for (size_t xx = x0; xx <= x1; ++xx) {
if (xx == x && yy == y) continue; // ignorer la cellule centrale
c += at(grid, w, xx, yy);
}
}
return c;
}Une logique ne doit exister qu’à un seul endroit. Si une même opération est dupliquée en deux endroits du code, toute correction ou évolution doit être appliquée deux fois — et l’oubli de l’une des deux copies est une source classique de bugs. Plus le projet grandit, plus la duplication devient coûteuse.
En C++, les templates et les fonctions génériques sont les outils naturels pour factoriser du code dupliqué. Cependant, DRY ne doit pas être appliqué aveuglément : éliminer toute duplication peut mener à des abstractions artificielles qui rendent le code plus difficile à comprendre. Deux blocs de code qui se ressemblent aujourd’hui peuvent évoluer dans des directions différentes demain. Une duplication locale et simple (2-3 lignes) est parfois préférable à une généralisation complexe qui couple des parties du code qui n’ont pas de raison de l’être.
Exemple (DRY) :
// Duplication (moins bon) : deux fonctions très similaires
double average_int(const std::vector<int>& v) {
if (v.empty()) return 0.0;
long sum = 0;
for (int x : v) sum += x;
return double(sum) / v.size();
}
double average_double(const std::vector<double>& v) {
if (v.empty()) return 0.0;
double sum = 0;
for (double x : v) sum += x;
return sum / v.size();
}
// Refactorisation (DRY) : une implémentation générique évite la duplication
template<typename T>
double average(const std::vector<T>& v) {
if (v.empty()) return 0.0;
long double sum = 0;
for (T x : v) sum += x;
return double(sum / v.size());
}
// Usage :
// std::vector<int> vi = {1,2,3};
// std::vector<double> vd = {1.0,2.0,3.0};
// double a1 = average(vi); // fonctionne pour int
// double a2 = average(vd); // fonctionne pour doubleNe pas implémenter des fonctionnalités “au cas où” si elles ne sont pas nécessaires aujourd’hui. Chaque fonctionnalité ajoutée prématurément a un coût : du code à maintenir, des tests à écrire, de la complexité à comprendre, et souvent une API plus lourde pour tous les utilisateurs — y compris ceux qui n’utiliseront jamais cette fonctionnalité.
En pratique, les développeurs surestiment fréquemment les besoins futurs. Un vecteur 3D n’a généralement pas besoin d’être généralisé en vecteur N-dimensionnel dès le départ. Un parseur de fichier n’a pas besoin de supporter 5 formats si un seul est utilisé. La bonne approche est de commencer par l’implémentation la plus simple qui répond au besoin actuel, puis de généraliser quand le besoin se manifeste réellement — pas avant.
Ce principe est particulièrement important en C++, où les templates, la généricité et la méta-programmation rendent très facile la construction de couches d’abstraction sophistiquées avant même d’avoir un cas d’utilisation concret.
Exemple (YAGNI) :
// Prématurément généralisé (YAGNI)
template <typename T = float, int N = 3>
struct vec { T data[N]; };
// Version simple et suffisante pour l'usage courant
struct vec3 { float x, y, z; };Un programme robuste ne se contente pas de “fonctionner dans les cas normaux” : il exprime explicitement ses hypothèses et vérifie qu’elles sont respectées.
Ces hypothèses constituent ce que l’on appelle le contrat du code.
Lorsqu’une fonction est appelée, deux points de vue existent :
Si ces règles sont implicites ou seulement “dans la tête du développeur”, le code devient fragile :
Le contrat permet de formaliser ces règles. L’ensemble de ces règles constitue ce que l’on appelle la programmation par contrat.
On distingue trois types de règles complémentaires.
Une précondition est une condition qui doit être vraie avant l’appel d’une fonction.
Exemples :
Une postcondition est une condition qui doit être vraie après l’exécution de la fonction.
Exemples :
Un invariant est une propriété qui doit être toujours vraie pour un objet valide.
Exemples :
Avant de voir du C++, voici une vue conceptuelle du contrat d’une pile.
Entité : Pile (Stack)
Invariant :
0 <= size <= capacity
Constructeur(capacity):
établit l'invariant
size := 0
capacity := capacity
push(value):
précondition : size < capacity
postcondition : top == value, size augmenté de 1
pop():
précondition : size > 0
postcondition : size diminué de 1
L’invariant doit être vrai après chaque appel public, quelle que soit la séquence d’opérations.
assert)Les assertions permettent de vérifier ces règles pendant l’exécution, principalement en phase de développement.
En C++, on utilise assert pour détecter des
erreurs de programmation.
#include <cassert>
float safe_div(float a, float b)
{
assert(b != 0.0f && "Division par zero");
return a / b;
}Ici :
b != 0.0f est une précondition,assert ?Les assertions permettent de :
Elles sont donc un outil de développement, pas un mécanisme de gestion d’erreurs utilisateur.
assertutiliser assert pour des erreurs de
programmation. Les assert sont théoriquement
“inutile” au bon fonctionnement du programme, ils ne servent qu’à
faciliter la programmation en détectant des cas inattendues/non prévus
qui ne devraient jamais arriver.
ne pas utiliser assert pour :
ne jamais écrire d’effets de bord :
assert(++i < 10); // interdit
// Ici la valeur de i est modifié après l'exécution de assert.
// Lors d'une compilation en mode "release", l'assertion n'est pas exécuté, et la valeur de i sera différente dans le programme.fournir un message explicite :
assert(ptr && "ptr ne doit pas être nul");assert sont
activesNDEBUG)Note: Le programme ne doit jamais dépendre des assertions pour fonctionner correctement.
static_assert)Certaines règles peuvent être vérifiées avant même l’exécution, à la compilation.
C’est le rôle de static_assert.
#include <type_traits>
template <typename T>
T square(T x)
{
static_assert(std::is_arithmetic_v<T>,
"square attend un type arithmetique");
return x * x;
}Ici :
static_assert ?Règle générale : préférer les vérifications à la compilation quand c’est possible.
#include <cassert>
#include <vector>
struct Stack {
std::vector<int> data;
size_t capacity;
// Invariant :
// 0 <= data.size() <= capacity
explicit Stack(size_t cap) : capacity(cap)
{
assert(capacity > 0 && "capacity doit être positive");
}
void push(int v)
{
// précondition
assert(data.size() < capacity && "push: pile pleine");
data.push_back(v);
// postcondition
assert(data.back() == v && "push: sommet incorrect");
}
int pop()
{
// précondition
assert(!data.empty() && "pop: pile vide");
int v = data.back();
data.pop_back();
// invariant toujours valide
assert(data.size() <= capacity && "invariant violé");
return v;
}
};Un contrat décrit ce que le code attend et garantit.
Les préconditions sont la responsabilité de l’appelant.
Les postconditions sont la responsabilité de la fonction.
Les invariants définissent les états valides d’un objet.
assert vérifie le contrat à l’exécution
(debug).
static_assert vérifie le contrat à la
compilation.
Utilisés correctement, ils rendent le code :
La fonction assert reste assez limité en terme de
fonctionalité. Des outils alternatifs peuvent aider à exprimer et
vérifier des contrats de façon plus lisible, sûre et maintenable pour
des codes de grande envergure :
Expects() / Ensures() (macros ou fonctions)
pour documenter pré/postconditions, ainsi que
not_null<T> et span<T> pour des
pointeurs et vues sûres.tl::expected / Outcome ou
std::expected quand disponible pour représenter
explicitement les erreurs récupérables au lieu d’exceptions ou codes
magiques.static_assert /
constexpr : remonter les vérifications au moment
de la compilation quand c’est possible (templates, contraintes de
types), réduisant le besoin d’assertions runtime.Boost.Contract et autres frameworks offrent des annotations
require/ensure/invariant plus riches (contrats activables/désactivables,
diagnostics centralisés).Expects(condition) permet d’uniformiser les
messages et d’activer des comportements différents selon la
configuration (throw, abort, log).Un programme peut sembler correct sur quelques exemples simples et
pourtant être faux dans des cas limites ou après une modification
ultérieure.
Les tests permettent de vérifier automatiquement que le
code respecte son comportement attendu, et surtout que ce comportement
reste correct dans le temps.
Tester ne consiste pas à prouver que le programme est parfait, mais à réduire le risque d’erreur et à détecter les problèmes le plus tôt possible.
Sans tests, la seule façon de vérifier qu’un programme fonctionne est de l’exécuter manuellement et d’observer les résultats. Cette approche ne passe pas à l’échelle : dès que le projet dépasse quelques centaines de lignes, il devient impossible de vérifier manuellement tous les cas après chaque modification. Les tests automatisés résolvent ce problème en codifiant les vérifications une fois pour toutes.
Les tests permettent de :
Dans un projet réel, les tests sont souvent exécutés automatiquement à chaque modification via un système d’intégration continue (CI) : à chaque commit, un serveur compile le code et exécute l’ensemble des tests. Si un test échoue, le développeur est immédiatement prévenu.
Un bon test est :
Un test unitaire vérifie une fonction ou une classe en isolation.
Ils sont rapides et très précis.
Ils sont idéaux pour tester : - fonctions mathématiques, - algorithmes,
- structures de données.
Un test d’intégration vérifie l’interaction entre plusieurs composants :
Ils sont plus lents mais plus proches du comportement réel.
Un test de non-régression est ajouté après la correction d’un bug.
Ces tests sont extrêmement précieux sur le long terme.
Un test lisible suit généralement la structure suivante :
Exemple :
// Arrange
float x = -1.0f;
// Act
float y = clamp(x, 0.0f, 1.0f);
// Assert
assert(y == 0.0f);Cette structure améliore la lisibilité et la maintenance des tests.
Pour une fonction donnée, il est recommandé de tester :
Tester uniquement le cas nominal est rarement suffisant.
On peut écrire des tests avec assert, mais il est
souvent utile d’avoir des messages plus explicites, notamment pour les
flottants.
#include <iostream>
#include <cmath>
#include <cstdlib>
inline void check(bool cond, const char* msg)
{
if (!cond) {
std::cerr << "[TEST FAILED] " << msg << std::endl;
std::exit(1);
}
}
inline void check_near(float a, float b, float eps, const char* msg)
{
if (std::abs(a - b) > eps) {
std::cerr << "[TEST FAILED] " << msg
<< " (a=" << a << ", b=" << b << ")" << std::endl;
std::exit(1);
}
}clampLa fonction clamp(x, a, b) :
a si x < a,b si x > b,x sinon.Précondition : a <= b.
#include <cassert>
float clamp(float x, float a, float b);
int main()
{
// cas nominal
assert(clamp(0.5f, 0.0f, 1.0f) == 0.5f);
// cas limites
assert(clamp(0.0f, 0.0f, 1.0f) == 0.0f);
assert(clamp(1.0f, 0.0f, 1.0f) == 1.0f);
// saturation
assert(clamp(-1.0f, 0.0f, 1.0f) == 0.0f);
assert(clamp( 2.0f, 0.0f, 1.0f) == 1.0f);
// violation de précondition (doit échouer en debug)
// clamp(0.0f, 1.0f, 0.0f);
}Implémentation :
#include <cassert>
float clamp(float x, float a, float b)
{
assert(a <= b && "clamp: intervalle invalide");
if (x < a) return a;
if (x > b) return b;
return x;
}La précondition relève ici du contrat : sa violation est une erreur de programmation.
Le TDD est une méthodologie dans laquelle le code est écrit en réponse à des tests. Elle inverse l’ordre habituel : au lieu d’écrire le code puis de le tester, on écrit d’abord le test qui décrit le comportement attendu, puis on écrit le code minimal qui satisfait ce test.
Cette inversion a un effet profond sur la conception : elle oblige à réfléchir à l’interface (comment la fonction sera appelée, quels paramètres, quels résultats) avant de réfléchir à l’implémentation. Le résultat est généralement un code plus modulaire, plus testable et plus simple.
Le TDD s’organise en cycles courts et itératifs :
Chaque cycle ajoute un petit incrément de fonctionnalité. Un cycle typique dure entre 2 et 10 minutes. Sur un projet réel, des dizaines de cycles s’enchaînent pour construire progressivement une fonctionnalité complète.
Le TDD :
Le TDD n’est pas adapté à toutes les situations. Il est particulièrement efficace pour du code algorithmique ou des API bien définies. Il est moins naturel pour du code exploratoire (prototypage) ou fortement lié à des ressources externes (GPU, réseau, interfaces graphiques).
v est non nul, normalize(v) retourne un
vecteur de norme 1,norm(v) > 0.#include <cassert>
#include <cmath>
struct vec3 { float x, y, z; };
float norm(vec3 const& v)
{
return std::sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}
vec3 normalize(vec3 const& v);
int main()
{
vec3 v{3.0f, 0.0f, 4.0f};
vec3 u = normalize(v);
assert(std::abs(norm(u) - 1.0f) < 1e-6f);
float dot = v.x*u.x + v.y*u.y + v.z*u.z;
assert(dot > 0.0f);
}#include <cassert>
#include <cmath>
vec3 normalize(vec3 const& v)
{
float n = norm(v);
assert(n > 0.0f && "normalize: vecteur nul");
return {v.x / n, v.y / n, v.z / n};
}Ensuite, on peut :
norm2,Les tests constituent une vérification automatique du contrat d’une fonction. Le TDD fournit une méthodologie simple pour écrire du code :
définir le comportement -> le vérifier automatiquement -> améliorer l’implémentation en confiance.
Utilisés correctement, les tests rendent le code plus fiable, plus lisible et plus facile à faire évoluer.
Tester uniquement les cas valides est insuffisant : un code robuste doit également détecter correctement les usages invalides. Il est donc essentiel d’écrire des tests qui vérifient que :
Ces tests négatifs permettent de s’assurer que le contrat du code est réellement respecté, et pas seulement dans les cas idéaux. Ils sont particulièrement importants lors des refactorisations : un changement interne ne doit jamais transformer une erreur détectée en comportement silencieux.
Selon la politique de gestion d’erreurs choisie, un test peut vérifier :
En pratique, tester les cas invalides est souvent aussi important que tester les cas valides, car c’est précisément dans ces situations que les bugs les plus coûteux apparaissent.
assertOn reprend la fonction normalize(v) vue précédemment. Sa
précondition est que le vecteur ne soit pas nul.
vec3 normalize(vec3 const& v)
{
float n = norm(v);
assert(n > 0.0f && "normalize: vecteur nul");
return {v.x / n, v.y / n, v.z / n};
}Il est important de vérifier que cette précondition est effectivement détectée.
// Test négatif : violation de précondition (doit échouer en debug)
int main()
{
vec3 zero{0.0f, 0.0f, 0.0f};
// Ce test n'est pas destiné à "passer" :
// en mode debug, l'assertion doit se déclencher.
// normalize(zero);
}Remarque :
Si l’on souhaite gérer les entrées invalides sans faire échouer le programme, on peut utiliser un type résultat.
#include <optional>
std::optional<vec3> normalize_safe(vec3 const& v)
{
float n = norm(v);
if (n <= 0.0f)
return std::nullopt;
return vec3{v.x / n, v.y / n, v.z / n};
}Test correspondant :
#include <cassert>
int main()
{
vec3 zero{0.0f, 0.0f, 0.0f};
auto r = normalize_safe(zero);
assert(!r.has_value()); // le cas invalide est bien détecté
}Ici, le test vérifie explicitement que :
La création de tests exhaustifs est souvent une tâche répétitive et chronophage. Pour une fonction ou une API non triviale, il faut généralement couvrir :
De plus, lorsque le code évolue (refactorisation, changement d’API, ajout de paramètres), les tests doivent être mis à jour afin de rester cohérents avec le nouveau contrat. Cette phase de maintenance peut représenter une part importante du temps de développement.
Dans ce contexte, les outils de génération de code assistée par IA peuvent être utilisés pour accélérer et faciliter la mise en place de batteries de tests. Ils sont particulièrement utiles pour :
Un programme robuste ne se contente pas de détecter les erreurs : il doit les classer, les signaler correctement, et permettre à l’appelant de réagir de manière appropriée.
La gestion des erreurs fait partie intégrante du design du code et de son API.
Dans un programme C++, une erreur non gérée peut avoir des conséquences graves : un accès mémoire invalide peut corrompre silencieusement des données, un dépassement de tampon peut écraser des variables voisines, et un comportement indéfini peut produire des résultats différents selon le niveau d’optimisation du compilateur. Contrairement à des langages avec garbage collector et vérifications automatiques, le C++ ne protège pas le développeur par défaut.
Sans stratégie claire de gestion des erreurs, on obtient :
Une bonne gestion des erreurs permet de rendre les échecs visibles et compréhensibles, de séparer le code nominal du code d’erreur, de tester explicitement les comportements invalides, et de renforcer le contrat entre appelant et fonction.
La première étape consiste à distinguer la nature de l’erreur.
Ce sont des situations qui ne devraient jamais arriver si le code est correctement utilisé.
Exemples :
Ces erreurs indiquent un bug.
Traitement recommandé :
assert,static_assert,assert(index < data.size() && "index hors limites");Ces erreurs ne sont généralement pas récupérables.
Ce sont des situations prévisibles, même si le code est correct.
Exemples :
Ces erreurs doivent être signalées à l’appelant.
Traitement recommandé :
optional, expected,
Result).Le choix d’une stratégie dépend :
Les exceptions permettent de séparer clairement le code
nominal du code d’erreur. Le principe est que le code normal
s’écrit comme si aucune erreur ne pouvait survenir, et les erreurs sont
traitées dans des blocs catch séparés. Si une erreur
survient dans une fonction profondément imbriquée, l’exception remonte
automatiquement la pile d’appels jusqu’à un catch
approprié, sans que chaque fonction intermédiaire n’ait besoin de
propager explicitement l’erreur.
float parse_float(std::string const& s)
{
return std::stof(s); // peut lever std::invalid_argument ou std::out_of_range
}
// Utilisation :
try {
float val = parse_float(user_input);
process(val);
} catch (std::invalid_argument const& e) {
std::cerr << "Entrée invalide : " << e.what() << std::endl;
}Avantages :
if (error)
à chaque ligne,Inconvénients :
À utiliser avec discipline : documenter les exceptions levées, et ne jamais utiliser les exceptions pour du contrôle de flux normal.
C’est l’approche héritée du C, encore très utilisée dans les API système et les bibliothèques bas niveau. La fonction retourne une valeur indiquant le succès ou l’échec, et le résultat est passé par paramètre de sortie.
bool read_file(std::string const& name, Data& out);
// Utilisation :
Data d;
if (!read_file("config.txt", d)) {
std::cerr << "Erreur de lecture" << std::endl;
return;
}Avantages :
Inconvénients :
[[nodiscard]]),bool ne dit pas pourquoi
l’opération a échoué.optional, expected, Result)Approche moderne et expressive.
std::optional<float> parse_float_safe(std::string const& s);Ou avec information d’erreur :
std::expected<float, ParseError> parse_float(std::string const& s);Avantages :
Souvent le meilleur compromis pour les API modernes.
#include <fstream>
#include <optional>
#include <string>
#include <vector>
struct ReadError {
enum class Code { FileNotFound, ParseError };
Code code;
std::string message;
int line = -1;
};
template <typename T>
struct Result {
std::optional<T> value;
std::optional<ReadError> error;
static Result ok(T v) { return {std::move(v), std::nullopt}; }
static Result fail(ReadError e) { return {std::nullopt, std::move(e)}; }
};Lecture d’un fichier contenant un flottant par ligne :
Result<std::vector<float>> read_floats(std::string const& filename)
{
std::ifstream file(filename);
if (!file.is_open()) {
return Result<std::vector<float>>::fail(
{ReadError::Code::FileNotFound, "Impossible d'ouvrir le fichier"});
}
std::vector<float> values;
std::string line;
int line_id = 0;
while (std::getline(file, line)) {
++line_id;
try {
values.push_back(std::stof(line));
} catch (...) {
return Result<std::vector<float>>::fail(
{ReadError::Code::ParseError, "Erreur de parsing", line_id});
}
}
return Result<std::vector<float>>::ok(std::move(values));
}Test minimal :
auto r = read_floats("data.txt");
assert(r.value.has_value() || r.error.has_value());Une API (Application Programming Interface) est l’interface de communication entre un morceau de code et ses utilisateurs (autres fonctions, autres modules, ou autres développeurs). Elle décrit comment utiliser le code, quelles opérations sont disponibles, quels paramètres sont attendus, et quels résultats ou erreurs peuvent être produits.
En C++, une API correspond le plus souvent à
l’ensemble des déclarations visibles dans les fichiers
d’en-tête (.hpp).
Ces fichiers décrivent ce que le code permet de faire,
sans exposer comment il le fait.
Concrètement, une API C++ est constituée de : - fonctions et leurs signatures, - classes et leurs méthodes publiques, - types (structures, énumérations, alias), - constantes et namespaces exposés.
L’utilisateur de l’API n’a besoin de lire que les fichiers d’en-tête pour comprendre : - comment appeler une fonction, - quels paramètres fournir, - quelles valeurs ou erreurs attendre, - et quelles règles (préconditions) doivent être respectées.
Les fichiers source (.cpp) contiennent l’implémentation
interne et peuvent évoluer librement tant que l’API, définie par les
en-têtes, reste inchangée.
Ainsi, en C++, concevoir une bonne API revient essentiellement à
concevoir de bons fichiers d’en-tête : clairs, cohérents, et
difficiles à mal utiliser.
Une API bien conçue doit être :
Une API doit indiquer clairement comment les erreurs sont signalées.
float normalize(vec3 const& v); // que se passe-t-il si v est nul ?Ici :
std::optional<vec3> normalize(vec3 const& v);Utilisation :
auto r = normalize(v);
if (!r) {
// cas invalide : v est nul
}L’erreur fait partie de l’API : elle ne peut pas être ignorée accidentellement.
vec3 normalize(vec3 const& v); // précondition : norm(v) > 0Ici :
assert.Choisir explicitement si l’erreur est récupérable ou non.
Les types doivent porter le sens, pas seulement les valeurs.
void load(int mode); // que signifie mode ?L’API permet des valeurs invalides (mode = 42).
enum class LoadMode { Fast, Safe };
void load(LoadMode mode);Utilisation :
load(LoadMode::Fast);Avantages :
void draw(bool wireframe); // que signifie true ?Meilleur design :
enum class RenderMode { Solid, Wireframe };
void draw(RenderMode mode);Une bonne API rend les états invalides impossibles ou difficiles à représenter.
struct Image {
unsigned char* data;
int width;
int height;
};Ici, rien n’empêche :
data == nullptr,width <= 0,class Image {
public:
Image(int w, int h)
: width(w), height(h), data(w*h*4)
{
assert(w > 0 && h > 0);
}
unsigned char* pixels() { return data.data(); }
private:
int width, height;
std::vector<unsigned char> data;
};Avantages :
L’API doit exposer ce que fait le code, pas comment il le fait.
.hpp) :
interface// image.hpp
class Image {
public:
Image(int w, int h);
void clear();
void save(const std::string& filename) const;
};.cpp) :
implémentation// image.cpp
#include "image.hpp"
void Image::clear()
{
// détails internes invisibles pour l'utilisateur
}Avantages :
Une fonction ne doit pas modifier des états globaux de manière inattendue.
void render()
{
global_state.counter++; // effet de bord caché
}void render(RenderContext& ctx)
{
ctx.counter++;
}Les dépendances sont explicites et testables.
bool, int
non documentés),Une bonne API empêche les erreurs avant même l’exécution du programme.
Elle guide l’utilisateur vers le bon usage, rend les erreurs explicites, et facilite les tests, la maintenance et l’évolution du code.
Ce chapitre montre que tout ce qu’on a vu en programmation — variables, types, opérations arithmétiques, mémoire, pointeurs — repose ultimement sur un composant physique unique : le transistor.
Un transistor est un interrupteur électronique minuscule. Comme un interrupteur mural, il a deux états : passant (le courant passe) ou bloqué (le courant ne passe pas). La différence cruciale avec un interrupteur mécanique est qu’on le commande par un signal électrique (une tension), et non manuellement. Un transistor peut donc être commandé par un autre transistor — c’est cette propriété qui rend possible la construction de circuits complexes.
En pratique, un transistor possède trois bornes :
En résumé :
0.1.C’est cette correspondance entre état électrique et valeur binaire qui est au fondement de toute l’informatique.
Sur les schémas électroniques, les transistors MOSFET sont
représentés par les symboles suivants. Le NMOS conduit
quand la tension de grille est haute (1), le
PMOS conduit quand elle est basse (0). Le
petit cercle sur la grille du PMOS indique cette inversion. Les deux
types sont utilisés de manière complémentaire dans la technologie
CMOS (Complementary MOS) qui équipe tous les
processeurs modernes.
Les premiers ordinateurs (années 1940-1950) utilisaient des tubes à vide — des ampoules de la taille d’un pouce qui jouaient le même rôle d’interrupteur commandé. Mais ils étaient volumineux, fragiles, et consommaient énormément d’énergie. L’invention du transistor à semi-conducteur (1947, Bell Labs) a tout changé : il est minuscule, fiable, rapide, et consomme très peu. Depuis, on a appris à en graver des milliards sur une puce de silicium de quelques centimètres carrés.
Les processeurs modernes utilisent des transistors de type MOSFET (Metal-Oxide-Semiconductor Field-Effect Transistor). Leur particularité est que la grille est isolée du canal par une fine couche d’oxyde : il suffit d’appliquer une tension (un champ électrique) pour contrôler le passage du courant, sans qu’aucun courant ne circule dans la grille elle-même. Cela réduit considérablement la consommation d’énergie, ce qui permet d’en empiler des milliards sans que la puce ne fonde.
Le fonctionnement d’un transistor repose sur les propriétés électriques du silicium, un matériau semi-conducteur. À l’état pur, le silicium conduit très mal le courant : ses électrons sont liés aux atomes par des liaisons covalentes et ne sont pas libres de se déplacer. Mais on peut modifier sa conductivité de manière contrôlée par un procédé appelé dopage.
Le dopage consiste à introduire une infime quantité d’atomes étrangers dans le cristal de silicium :
La conductivité du silicium dopé dépend de la concentration en impuretés, ce qui permet de la contrôler avec une grande précision lors de la fabrication.
Un transistor MOSFET de type N (le plus courant dans les processeurs) est constitué de :
Entre la source (dopée N) et le drain (dopé N), le substrat est dopé P. À l’interface entre les zones N et P, les électrons libres et les trous se recombinent, créant une zone de déplétion (appauvrie en porteurs). Cette zone agit comme une barrière : aucun courant ne circule entre source et drain.
Lorsqu’on applique une tension positive sur la grille, le champ électrique généré à travers l’oxyde repousse les trous du substrat P (qui s’éloignent de la surface) et attire les électrons minoritaires vers la surface, juste sous l’oxyde. Si cette tension dépasse un seuil critique appelé tension de seuil (\(V_{th}\)), la concentration en électrons sous la grille devient suffisante pour former un mince canal conducteur de type N entre la source et le drain. Le courant peut alors circuler.
01La commutation entre ces deux états est ce qui permet de représenter l’information binaire.
L’oxyde isolant sous la grille est la caractéristique fondamentale du MOSFET. Grâce à cette isolation :
Les anciens transistors bipolaires nécessitaient un courant continu pour maintenir la commande, ce qui les rendait beaucoup plus énergivores.
À mesure que les transistors sont miniaturisés, la couche d’oxyde devient si fine (quelques atomes d’épaisseur) que des phénomènes quantiques apparaissent :
Ces contraintes expliquent pourquoi la fréquence des processeurs a cessé d’augmenter vers 2005 (~4 GHz) et pourquoi l’industrie s’est tournée vers les architectures multicœurs.
Ordres de grandeur :
Un transistor seul ne fait pas grand-chose. Mais en combinant deux ou trois transistors, on peut construire des circuits qui réalisent des opérations logiques sur des bits. Ces circuits élémentaires s’appellent des portes logiques.
C’est la porte la plus simple : elle inverse un signal. Si l’entrée
est 1, la sortie est 0, et inversement.
Elle se construit avec 2 transistors (un de type N, un de type P) arrangés de sorte que :
1), le transistor du bas
conduit et tire la sortie vers le 0 ;0), le transistor du haut
conduit et tire la sortie vers le 1.| Entrée | Sortie |
|---|---|
| 0 | 1 |
| 1 | 0 |
La porte AND prend deux entrées et produit 1 uniquement
si les deux entrées sont à 1. Elle
nécessite environ 6 transistors.
| A | B | A AND B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
En C++, c’est exactement l’opérateur & (bit à bit)
ou && (logique).
La porte OR produit 1 si au moins une
des entrées est à 1. Elle nécessite également environ
6 transistors.
| A | B | A OR B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
En C++, c’est l’opérateur | (bit à bit) ou
|| (logique).
La porte XOR (ou exclusif) produit 1 si les deux entrées
sont différentes. Elle est essentielle pour l’addition
binaire.
| A | B | A XOR B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
En C++, c’est l’opérateur ^.
Les opérations bit à bit vues dans le chapitre sur l’encodage
(&, |, ^, ~,
<<, >>) correspondent
directement à des portes logiques matérielles. Quand on
écrit a & b en C++, le processeur active littéralement
un circuit AND qui compare chaque paire de bits des deux opérandes. Il
n’y a aucune abstraction intermédiaire : le code C++ se traduit en une
instruction machine, qui active une porte logique physique.
L’opération a + b en C++ se traduit en un circuit
construit à partir de portes logiques.
Pour additionner deux bits A et B, on a
besoin de deux résultats :
La table de vérité de l’addition de deux bits est :
| A | B | Somme (S) | Retenue (C) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 1 |
Les résultats correspondent à : S = A XOR B et
C = A AND B. Un demi-additionneur se construit avec
une porte XOR et une porte AND, soit une dizaine de
transistors.
Pour additionner des nombres de plusieurs bits, chaque position doit aussi prendre en compte la retenue entrante de la position précédente. Un additionneur complet prend trois entrées (A, B, retenue entrante) et produit deux sorties (somme, retenue sortante). Il est construit avec environ 28 transistors.
Pour additionner deux entiers de 32 bits (un int en
C++), on chaîne 32 additionneurs complets, chacun
recevant la retenue du précédent. C’est une cascade :
le résultat de chaque position dépend de la retenue de la position
inférieure. Dans les processeurs modernes, des techniques comme le
carry-lookahead permettent de calculer les retenues en
parallèle pour accélérer l’opération.
Ainsi, une simple addition a + b en C++ mobilise environ
un millier de transistors travaillant en concert.
La soustraction a - b n’est pas implémentée comme une
opération séparée. Comme vu dans le chapitre sur l’encodage, grâce au
complément à deux, soustraire b revient à
additionner le complément de b. Le processeur inverse les
bits de b, ajoute 1, puis utilise le même circuit
d’addition. C’est pourquoi l’addition et la soustraction sont aussi
rapides l’une que l’autre.
Quand on écrit if (a < b) en C++, le processeur
effectue en réalité la soustraction a - b et examine les
drapeaux (flags) résultants : le bit de signe (le
résultat est-il négatif ?), le bit de zéro (le résultat est-il nul ?),
etc. Les opérateurs <, >,
==, != ne sont donc pas des opérations
distinctes — ce sont des tests sur le résultat d’une soustraction.
La multiplication est plus complexe : elle repose sur des additions partielles et des décalages, similaires à la multiplication posée qu’on apprend à l’école. Les processeurs modernes disposent d’unités de multiplication dédiées et fortement optimisées, mais l’opération reste plus coûteuse qu’une addition (typiquement 3 à 5 cycles au lieu de 1).
La division est l’opération arithmétique la plus coûteuse. Elle est généralement réalisée par un algorithme itératif interne au processeur, et peut prendre 20 à 40 cycles. C’est pourquoi les compilateurs remplacent souvent les divisions par des constantes par des multiplications équivalentes.
Tous ces circuits (addition, soustraction, opérations logiques, comparaisons, décalages) sont regroupés dans une unité appelée l’ALU (Arithmetic Logic Unit). L’ALU reçoit :
Les opérations sur les nombres flottants sont gérées par une unité séparée, la FPU (Floating Point Unit), qui réalise l’alignement des exposants, les opérations sur les mantisses, la normalisation et l’arrondi IEEE 754. Ces opérations sont plus coûteuses que les opérations entières, mais entièrement câblées en matériel.
Les processeurs modernes disposent également d’unités
vectorielles (SIMD — Single Instruction, Multiple Data)
capables d’appliquer une même opération sur plusieurs données
simultanément. Par exemple, une instruction SSE peut
additionner 4 float en parallèle en un seul cycle. Ce
mécanisme est massivement utilisé en informatique graphique, en
traitement du signal et en calcul scientifique.
Au-delà du calcul, un processeur doit aussi
mémoriser des résultats. Stocker un bit nécessite un
circuit capable de maintenir un état (0 ou 1)
de manière stable. Les transistors permettent aussi cela, mais avec un
agencement différent de celui des portes logiques.
L’idée fondamentale est la rétroaction : on connecte
la sortie d’une porte logique à l’entrée d’une autre, et vice versa. Les
deux portes se maintiennent mutuellement dans un état stable — soit
0, soit 1. Cet agencement s’appelle une
bascule (ou latch).
Une bascule élémentaire utilise deux portes NOR (ou NAND) croisées, soit environ 8 à 12 transistors pour stocker un seul bit. Tant que le circuit est alimenté, le bit est conservé sans intervention. Pour le modifier, on envoie un signal sur les entrées de commande.
C’est le principe qui sous-tend les registres du processeur et la mémoire cache.
La SRAM (Static RAM) utilise des bascules — typiquement 6 transistors par bit (cellule 6T). Elle est :
C’est pourquoi la SRAM est réservée aux registres du processeur et aux mémoires cache (L1, L2, L3), où la vitesse est critique et la quantité de données est relativement faible.
La DRAM (Dynamic RAM) utilise une approche radicalement différente : chaque bit est stocké sous forme de charge électrique dans un minuscule condensateur, contrôlé par un seul transistor. C’est beaucoup plus compact (1 transistor + 1 condensateur par bit, contre 6 transistors pour la SRAM), ce qui permet de stocker des gigaoctets sur une seule barrette.
Le prix à payer :
C’est la mémoire que l’on appelle communément “la RAM” de
l’ordinateur. Quand on écrit int a = 42; en C++, la valeur
42 est stockée quelque part dans cette grille de
condensateurs.
La mémoire flash (SSD, clés USB) repose sur un transistor modifié possédant une grille flottante isolée électriquement. On y piège des électrons par injection à haute tension. Ces électrons restent piégés même sans alimentation — c’est ce qui rend la mémoire non volatile. La lecture est plus lente (50–100 µs), l’écriture encore plus (200 µs à quelques ms), et le nombre de cycles d’écriture est limité, mais la persistance sans alimentation est indispensable pour le stockage de données.
| Type | Volatile | Structure par bit | Vitesse | Usage |
|---|---|---|---|---|
| SRAM | oui | 6 transistors | 0,3 – 2 ns | registres, caches |
| DRAM | oui | 1 transistor + 1 condo | 50 – 100 ns | mémoire principale |
| Flash | non | 1 transistor (modifié) | 50 – 100 µs | stockage persistant |
Le processeur est capable d’effectuer une addition en 1 cycle (environ 0,3 ns à 3 GHz). Mais accéder à une donnée en RAM prend 100 à 300 cycles. Sans mécanisme intermédiaire, le CPU passerait la majeure partie de son temps à attendre la mémoire, inactif.
C’est le problème fondamental de l’architecture moderne : le processeur est bien plus rapide que la mémoire.
Le cache est une petite quantité de SRAM intégrée directement dans le processeur, qui stocke des copies de blocs de mémoire récemment utilisés. Son efficacité repose sur deux principes : la localité temporelle (une donnée récemment utilisée a de fortes chances d’être réutilisée) et la localité spatiale (si on accède à une adresse mémoire, les adresses voisines seront probablement utilisées aussi).
Les processeurs modernes ont typiquement trois niveaux de cache :
| Niveau | Taille typique | Latence | Partagé |
|---|---|---|---|
| L1 | 32 – 64 Ko | 3 – 5 cycles | par cœur |
| L2 | 256 Ko – 1 Mo | ~10 cycles | par cœur |
| L3 | 8 – 64 Mo | ~30 cycles | entre cœurs |
Quand le processeur a besoin d’une donnée, il cherche d’abord dans le L1. Si elle n’y est pas (cache miss), il cherche dans le L2, puis le L3, et enfin la RAM. Chaque niveau est plus grand mais plus lent que le précédent.
La hiérarchie de cache explique pourquoi certains patterns de code sont beaucoup plus rapides que d’autres, à nombre d’opérations égal :
Accès séquentiel (cache-friendly) :
// Très rapide : accès contigus, excellente localité spatiale
for (int i = 0; i < N; ++i)
sum += array[i];Quand le processeur charge array[0] depuis la RAM, il
charge en fait une ligne de cache entière (typiquement
64 octets, soit 16 int). Les accès suivants
(array[1], array[2], …) sont donc déjà dans le
cache — ils sont quasi instantanés.
Accès aléatoire (cache-hostile) :
// Beaucoup plus lent : chaque accès peut provoquer un cache miss
for (int i = 0; i < N; ++i)
sum += array[random_index[i]];Ici, chaque accès saute à un endroit imprévisible du tableau. La ligne de cache chargée est rarement réutilisée, et le processeur passe son temps à attendre la RAM.
C’est exactement la raison pour laquelle les std::vector
(contiguïté mémoire) sont bien plus performants que les
std::list (éléments dispersés sur le tas), et pourquoi
l’organisation AoS vs SoA (vue dans le chapitre sur les pointeurs) a un
impact majeur sur les performances.
int a = 42; aux transistorsLe chemin complet d’une ligne de C++ jusqu’au matériel :
int a = 5;
int b = 3;
int c = a + b;Le compilateur traduit ce code en instructions machine (assembleur) : “charger 5 dans le registre R1, charger 3 dans R2, additionner R1 et R2 et stocker dans R3”.
Les valeurs 5 et 3
sont des configurations de bits (00000101 et 00000011) stockées sous
forme de charges électriques dans des condensateurs (DRAM) ou de
bascules (SRAM/registres) — dans les deux cas, des
transistors.
L’addition est réalisée par l’ALU : 32 additionneurs complets chaînés, chacun composé de portes XOR et AND, elles-mêmes faites de transistors. Les retenues se propagent de bit en bit (ou sont calculées en parallèle par un carry-lookahead).
Le résultat 8 (00001000) est stocké
dans un registre (bascules SRAM, 6 transistors par bit × 32 bits = 192
transistors pour un seul int).
Si c est ensuite utilisé dans une condition
(if (c > 0)), le processeur effectue la soustraction
c - 0, examine le flag de signe, et décide quel chemin
d’exécution suivre.