Introduction au C++

Préambule

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.

Évolutions du C++

Le langage C++ continue à intégrer des évolutions régulières.

Pourquoi utiliser le C++ ?

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.

Domaines d’application

Points forts (+)

Points faibles (−)

Comparaison rapide avec d’autres langages

Premier programme en C++

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; 
}

Explications ligne par ligne

  1. #include <iostream>
  2. int main()
  3. std::cout << "Hello, world!" << std::endl;
  4. 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.

Première compilation (sous Linux/MacOS)

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 hello

L’execution du programme se réalise avec la commande

./hello

Ce qui doit afficher le résultat suivant

Hello, world!

Déclaration de variables

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.).

Exemple simple

#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;
}

Types fondamentaux

Vous utiliserez principalement deux types fondamentaux dans vos codes :

Vous rencontrerez également les types suivants :

Précisions sur les types

  1. 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.5
  2. Le 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;  // double

    Pour 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.

Déclaration sans initialisation (exemple)

int compteur;    // non initialisé
compteur = 10;  // affectation d'une valeur plus tard

Attention : une variable non initialisée contient une valeur indéfinie et ne doit pas être utilisée avant affectation.

Variables constantes (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;
}

Intérêt

Conversion de types (cast)

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 :

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.

Affichage et lecture formatés : printf, scanf

printf et scanf (hérités du C)

En plus de std::cout et std::cin, C++ conserve les fonctions classiques du langage C :

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.

Exemple d’affichage formaté avec 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
Exemple de lecture avec scanf
#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.

Principaux spécificateurs de format (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

Conteneurs d’éléments contigus, tableaux

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 :

Exemple simple avec 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;
}

Sécurité d’accès

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;

Redimensionnement

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 à 0

Comparaison std::array, std::vector et tableaux C

Visualisation mémoire d’un tableau C contigu
#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;
}

Comparaison des trois types de tableaux

Conditionnelles et boucles

if / else

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;
}

if / else if / else

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;

Les boucles

La boucle while

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++;
}

La boucle do … while

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);

La boucle for

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;
}

La boucle for étendue (C++11)

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;
}

Extension : switch / case

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.

Conteneurs associatifs : std::map

Un 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)).

Structure arborescente d’un std::map

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 :

Durée de vie des variables

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.

Durée de vie des variables selon leur scope

Exemple 1 : variable locale à un 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
}

Exemple 2 : variable définie dans un bloc englobant

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()
}

Différences avec d’autres langages

Fonctions

En 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;
}

Exemple simple

int addition(int a, int b)
{
    return a + b;
}

Déclaration et définition

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.

Exemple correct (définition avant utilisation)

int addition(int a, int b)
{
    return a + b;
}

int main()
{
    int c = addition(5, 3); // OK
}

Exemple correct (déclaration puis définition)

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;
}

Exemple incorrect

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;
}

Exemple : fonction 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

Fonctions mathématiques utiles

Ne pas utiliser ^ ni ** en C++ : ce ne sont pas des opérateurs de puissance.

Surcharge de fonctions (Function Overloading)

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).

Exemple

#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;
}

Synthèse sur les fonctions

Passage d’arguments: copie, référence

Passage par copie vs passage par référence

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.

Exemple avec passage par copie

#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.

Passage par référence

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.

Exemple avec std::vector

Considé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é.

Passage par référence (correction)

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

Références constantes

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.

Classes

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.

Déclaration et utilisation d’un objet simple

#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;
}

Struct vs Class

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 :

Méthodes (fonctions membres)

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;
}

Remarques

Constructeurs et destructeur

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()
}

Constructeur ou destructeur par défaut (= 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 :

Fonctions membres vs fonctions non membres

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.

Écriture/lecture de fichiers externes

En C++, la bibliothèque <fstream> permet d’écrire et de lire des données dans des fichiers. Celle-ci fournit trois classes principales :

Exemple : écriture d’un vecteur dans un fichier

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

Exemple : lecture d’un vecteur depuis un fichier

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)

Modes d’ouverture

Lors de l’ouverture d’un fichier, on peut préciser des modes :

Exemple :

std::ofstream file("log.txt", std::ios::app); // ouverture en ajout
file << "Nouvelle entrée" << std::endl;

Organisation des fichiers de code

Organisation des fichiers hpp, cpp et main

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 :

  1. Fichier d’en-tête (.hpp ou .h)

  2. Fichier d’implémentation (.cpp)

  3. Fichier principal ou d’utilisation (main.cpp, etc.)

Exemple : organisation avec une classe vec3

Fichier d’en-tête — vec3.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);

Fichier d’implémentation — 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;
}

Fichier d’utilisation — 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;
}

Fonctionnement des inclusions

À propos de #pragma once

La 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.

Compilation

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.

Schéma simple du pipeline de compilation

Fichier source (.cpp)
        ↓ (compilateur)
   Fichier objet (.o)
        ↓ (linker / éditeur de liens)
   Exécutable (programme binaire)

Schéma avec plusieurs fichiers sources

Pipeline de compilation multi-fichiers

Exemple de code assembleur

Exemple C++

int add(int a, int b) {
    return a + b;
}

int main() {
    int x = add(2, 3);
    return x;
}

Exemple d’assembleur généré (x86-64, simplifié)

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 eax

Explications

Sous Linux/MacOS

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 programme

ou

clang++ main.cpp -o programme

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

Windows

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.

Meta-configuration via CMake

Pour éviter d’écrire un Makefile spécifique à Linux et un projet Visual Studio spécifique à Windows, on utilise CMake.

Exemple d’utilisation sous Linux/MacOS:

# Depuis le répertoire du projet
mkdir build
cd build
cmake ..
make          # sous Linux/MacOS

En résumé

Types fondamentaux, encodage

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 :

Encodage des entiers

Représentation binaire

À 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.

Encodage d’un entier en mémoire

Entiers non signés

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 :

Entiers signés et complément à deux

Pour 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 :

  1. On part de la représentation binaire de la valeur positive.
  2. On inverse tous les bits (les 0 deviennent 1 et inversement).
  3. On ajoute 1 au résultat.

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 :

Encodage des entiers signés en complément à deux

Exemple pratique

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 :

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.

Encodage des nombres flottants

Les flottants (float, double) suivent la norme IEEE 754.

Un nombre flottant est représenté par trois parties :

  1. Signe (1 bit)
  2. Exposant (8 bits pour float, 11 bits pour double)
  3. Mantisse (23 bits pour float, 52 bits pour double)

Formule :

\[ x = (-1)^s \times (1 + mantisse) \times 2^{exposant - biais} \]

Disposition des bits d’un flottant IEEE 754 : signe, exposant, mantisse

Exemple : 46 3F CC 30 (float en hexadécimal) = 12275.046875 en décimal.

Propriétés à connaître :

Densité des nombres flottants sur l’axe réel

Erreurs d’arrondi et comparaison de flottants

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) { ... }  // OK

Tolé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.

Notion d’endianness

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).

Deux conventions principales

  1. Big Endian (certaines architectures réseau, PowerPC, anciens processeurs)

  2. Little Endian (Intel x86, ARM en mode par défaut)

Intérêt du Little Endian

Le 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).

Attention à la préservation d’Endianness

Synthèse des types fondamentaux

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.

Obtenir la taille avec sizeof

En 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

Types à tailles spécifiques

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 :

Exemples utiles complémentaires :

Exemple 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;
}

Opérations bit à bit

Opérations bit à bit : AND, OR, XOR, NOT, shifts

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++ :

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 besoin

Masques 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 2

Conseils importants

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 7

Pointeurs

Notion de stockage et d’adressage en mémoire

Pour 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.

Variables stockées en mémoire avec leurs adresses

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.

Adresse d’une variable

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).

Exemple simple

#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

Lecture et écriture via la fonction C scanf

Quand 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;
}

Observation de l’adresse

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.).

Initialisation des pointeurs

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;
}

Exemple de mauvaise pratique

int* p;      // pointeur non initialisé (dangereux !)
*p = 10;     // comportement indéfini → crash probable

Ici, p contient une valeur indéterminée : accéder à *p est dangereux.

Exemple correct

int* p = nullptr;   // pointeur sûr, mais vide
if(p != nullptr) {
    *p = 10;        // on accède uniquement si p pointe vers une variable valide
}

Passage d’argument

Passage par valeur (comportement par défaut)

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 :

Passage par adresse avec un pointeur

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 :

  1. Dans main, on a la variable a (valeur 5) stockée à une certaine adresse mémoire (par ex. 1000).

  2. L’expression &a produit cette adresse (1000).

  3. Lors de l’appel increment(&a), ce n’est pas a qui est copié, mais son adresse (1000).

  4. À l’intérieur de increment, *p signifie « la valeur contenue à l’adresse p ».

  5. Comme p désigne la mémoire de a, la variable a est réellement modifiée.

En résumé :

Passage par valeur vs passage par adresse

Cas des tableaux contigus

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.

Tableau contigu en mémoire

Tableaux C

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.

Arithmétique des pointeurs

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 :

Arithmétique des pointeurs : décalage selon sizeof(type)

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)

Schéma mémoire (exemple avec tab[3])

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

Adaptation à la taille mémoire des éléments

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.

Exemple avec 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.

Tableaux dynamiques en C++ : std::vector

En C++ moderne, on utilise std::vector plutôt que des tableaux statiques, car il offre :

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.

Arithmétique des pointeurs sur std::vector

On 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 :

Contiguité dans les classes et struct

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.

Exemple simple

#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ë.

Padding et alignement

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)
Padding et alignement dans les structs

Exemple avec plusieurs champs

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.

Contiguïté dans les classes

En C++, une class se comporte comme une struct du point de vue mémoire :

std::vector de structures

En 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;
}

Schéma ASCII d’un 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é :

Organisation mémoire AoS vs SoA

AoS vs SoA : organisation mémoire

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 :

Array of Structs (AoS)

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 :

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.

Struct of Arrays (SoA)

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 :

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).

Contiguïté : deux visions complémentaires

Les deux approches utilisent donc la contiguïté mémoire, mais pas au même niveau de structuration.

Choix en pratique

Allocation dynamique

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).

Pile (stack) vs tas (heap)

Comparaison pile (stack) et 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.

Problème : durée de vie des variables locales

#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).

Solution : allocation sur le tas

#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.

Allocation dynamique en C : malloc et free

En C, on utilise les fonctions de la bibliothèque standard <stdlib.h>.

#include <stdlib.h>

int* p = (int*)malloc(sizeof(int));

Ici :

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 :

Allocation de tableaux dynamiques en C

int* tab = (int*)malloc(10 * sizeof(int));

Accès :

tab[0] = 1;
tab[1] = 2;

Libération :

free(tab);

Allocation dynamique en C++ : new et delete

En 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 :

Les mélanger conduit à un comportement indéfini.

Allocation d’objets et appel des constructeurs

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é
}

Allocation dynamique d’un tableau (exemple complet)

#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.

Schéma mémoire

Pile (stack)                   Tas (heap)
------------                   ------------
int main() {                   new int[3]
  int n = 3;                   ---------------
  int* arr = new int[n]; -->   | 0 | 1 | 2 | ...
                               ---------------
}

Problèmes classiques

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 :

Problèmes classiques de la mémoire dynamique
  1. 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.

  2. 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 double

    Cela 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.

  3. 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éfini

    Ce bug est difficile à détecter car il peut fonctionner “par chance” dans certaines exécutions et planter dans d’autres.

Bonne pratique : mettre à nullptr après libération

Une 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.

Redimensionnement (principe)

Quand on redimensionne un tableau dynamique manuellement, il faut :

  1. Allouer un nouvel espace.
  2. Copier les anciennes données.
  3. Libérer l’ancien espace.
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.

Structures dynamiques : listes et graphes

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.

Exemple : liste chaînée minimale

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.

Gestion moderne de la mémoire

En C++, on évite aujourd’hui new / delete directs. On privilégie :

1. std::vector pour les tableaux dynamiques

Exemple:

#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).

2. Pointeurs intelligents (std::unique_ptr, std::shared_ptr)

unique_ptr vs 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é.

Exemple avec std::unique_ptr

Exemple:

#include <memory>
#include <iostream>

int main() {
    std::unique_ptr<int> p = std::make_unique<int>(42);
    std::cout << *p << std::endl;
} // delete automatique ici

Explication :

Caractéristiques de std::unique_ptr :

Exemple avec std::shared_ptr

Exemple:

#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ît

Explication détaillée :

Ainsi, la mémoire est libérée exactement quand elle n’est plus utilisée par personne.

Caractéristiques de std::shared_ptr :

Comparaison des deux types de pointeurs intelligents
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
Illustration mémoire
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
Pourquoi les pointeurs intelligents remplacent new et delete

La copie memoire: memcpy

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++).

Prototype

#include <string.h>

void* memcpy(void* dest, const void* src, size_t n);

Exemple simple : copier un tableau d’entiers

#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.

Exemple : copier une structure simple

#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;
}

Lire un “buffer brut” et reconstruire des types avec memcpy

Cas 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 :

Soit : 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;
}

Le pointeur générique 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.

Déclaration et principe

void* p;

Ici :

Cela signifie que :

Exemple simple

#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 :

Impossibilité de déréférencer directement

Il est interdit de faire :

void* p = &a;
printf("%d\n", *p); // ERREUR

Pourquoi ?

Le type void signifie littéralement : absence d’information de type.

Conversion explicite (cast)

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 :

  1. p contient l’adresse de a,
  2. on indique explicitement au compilateur : « considère cette adresse comme un int* »,
  3. on peut alors déréférencer correctement.

Exemple avec plusieurs types

#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 :

Lien avec l’arithmétique des pointeurs

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 :

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 brute

Le 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 :

Exemple plus complet d’utilisation de 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 :

  1. un en-tête (header) avec :

  2. puis 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”.

#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;
}

Usage en pratique

Le void* est principalement utilisé :

En C++ moderne, on préfère :

Les casts en C++

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-style

Ce 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 compatibles

C’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 Derived

Si 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 const

Sert à 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'appel

Attention : 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émoire

Ré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 a

C’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.

Les différents types de cast

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_cast dans la grande majorité des cas. Si vous avez besoin de const_cast ou reinterpret_cast, c’est souvent le signe que le design mérite d’être reconsidéré.

Références

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 :

Passage d’arguments : comparaison valeur, pointeur, référence

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 :

Passage par adresse avec pointeur (style C)

#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 :

Passage par référence (style C++)

#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 :

Initialisation des références

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 :

Références constantes

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.

Exemple concret : vecteurs et structures

#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
}

Accesseurs par référence

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;
}

Bonnes pratiques

À faire

À éviter

Classes

Introduction

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.

Regrouper des données : premier exemple avec struct

On 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.

Ajouter un comportement : méthodes

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 = 3

Remarque : 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.

Le pointeur implicite this

Dans 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 class

Le 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.

Attributs publics et privé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 :

Encapsulation et sécurité

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.

Encapsulation : différence entre struct (public) et class (private)

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.

Initialization, constructeurs

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.

Problème classique : attributs non initialisés

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=0

Mais dès qu’on veut contrôler précisément l’état de l’objet, on utilise des constructeurs.

Constructeur par défaut

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.

Liste d’initialisation

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.

Constructeurs surchargés

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)
}

Constructeur à un argument et explicit

Un 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 à explicit

Cela rend le code plus sûr et plus lisible.

Membres const et références : constructeur obligatoire

Les 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.

Destructeur (rappel)

Durée de vie d’un objet : constructeur, utilisation, destructeur

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.

Opérateurs

Surcharge d’opérateurs : traduction par le compilateur

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.

Principe général

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 + b

est 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é.

Opérateurs membres et non-membres

Un opérateur peut être défini :

Règle courante :

Exemple : opérateurs arithmétiques pour un vecteur 3D

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)

Opérateurs avec types différents

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;

Opérateurs de comparaison

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.

Opérateur d’accès []

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.

Opérateur d’affichage <<

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.

Héritage

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.

Hiérarchie d’héritage : Shape → Circle, Rectangle

Principe général

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.

Exemple simple d’héritage

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 Shape

La classe Circle hérite automatiquement de x, y et de la méthode translate.

Constructeurs et héritage

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.

Accès aux membres : public, protected, private

Niveaux d’accès public, protected et private

Le niveau d’accès des membres de la classe de base détermine leur visibilité dans la classe dérivée :

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
    }
};

Redéfinition de méthodes

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.

Héritage et factorisation du code

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.

Polymorphisme

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.

Le problème : stocker des objets différents dans un même conteneur

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 rectangles

et surtout impossible de faire :

std::vector</* Circle et Rectangle */> shapes; // impossible

Sans polymorphisme, on est contraint soit :

Le polymorphisme fournit une solution élégante à ce problème.

Interface commune via une classe de base

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 :

Classes dérivées spécialisées

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;
    }
};

Stockage polymorphique dans un conteneur

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 :

Rôle de virtual et du dispatch dynamique

L’appel :

s->area();

est résolu à l’exécution grâce à la table virtuelle :

C’est le cœur du polymorphisme dynamique.

Table virtuelle (vtable) pour le dispatch dynamique

Importance du destructeur virtuel

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.

Pourquoi des pointeurs et pas des objets ?

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.

Problème du slicing lors de la copie d’un objet dérivé dans un objet de base

Coût et alternatives

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.

Utilisation de pointeurs bruts (raw pointers)

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.

Exemple avec pointeurs bruts

#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 :

Rôle critique du destructeur virtuel

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.

Problèmes fréquents avec les pointeurs bruts

L’utilisation de pointeurs bruts expose à plusieurs erreurs classiques :

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.

Gestion d’accès : const

En 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.

Sens d’une méthode const

Une 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;
}

Objets constants et méthodes accessibles

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 const

Cela impose naturellement une séparation claire entre :

Méthodes const et non const : deux signatures différentes

Une 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 :

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 const

Le compilateur choisit automatiquement la version appropriée en fonction du caractère const de l’objet.

Exemple classique : accesseur en lecture et écriture

class Buffer {
  public:
    float& value() {
        return data;
    }

    float value() const {
        return data;
    }

  private:
    float data;
};

Ici :

Buffer b;
b.value() = 3.0f; // version non const

const Buffer c;
// c.value() = 3.0f; // ERREUR
float v = c.value(); // version const

Intérêt conceptuel

Cette 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.

Gestion d’accès : le mot-clé static dans les classes

Attribut statique partagé vs attributs d’instance

Le 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.

Attributs statiques

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 = 3

Tous les objets Counter partagent la même variable count.

Accès aux attributs statiques

Un attribut statique :

Counter::get_count(); // forme recommandée

Cela souligne le fait que la donnée appartient à la classe, et non à une instance particulière.

Méthodes statiques

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);

Contraintes des méthodes statiques

Une méthode statique :

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 initialisation

Depuis 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.

Cas d’usage courants

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 static est unique et partagé : il appartient à la classe, pas aux objets.

Espaces de noms (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.

Déclaration et utilisation

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 math

Utilisation :

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.

Exemple : éviter un conflit de noms

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 :

1) Importer un nom précis (recommandé)

using math::vec3;

vec3 v{1,2,3}; // équivalent à math::vec3

2) Importer tout un namespace (à éviter dans un header)

using namespace std;

Cela permet d’écrire vector au lieu de std::vector, mais peut créer des conflits.

Bonne pratique :

Espaces de noms imbriqués

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; };
}

Espaces de noms anonymes (visibilité locale)

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 :

Alias de namespace

Utile si un nom est long :

namespace em = engine::math;

em::vec2 v{1,2};

Pointeurs de fonctions, foncteurs et lambdas

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).

Pointeurs de fonctions

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;

Passer une fonction en argument

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.

Limites des pointeurs de fonctions

Les pointeurs de fonctions ont deux limitations importantes :

int seuil = 10;

// Impossible : un pointeur de fonction ne peut pas "voir" seuil
// bool above_seuil(int x) { return x > seuil; } // seuil doit être global

Ces limitations motivent l’introduction des foncteurs et des lambdas.

Foncteurs (objets-fonctions)

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)

Lambdas

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; // 25

Captures

Les 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érence

Raccourcis :

Équivalence avec un foncteur

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 callable

Les 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).

Coût de std::function

std::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 compilateur

C’est d’ailleurs l’approche utilisée par les algorithmes de la bibliothèque standard.

Cas d’usage typiques

// 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;
};

Bibliothèque standard (STL) : conteneurs, itérateurs, algorithmes

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.

Conteneurs

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.

Conteneurs séquentiels

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.

Adaptateurs de conteneurs

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 3

std::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 1

std::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();

Conteneurs associatifs

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.

Synthèse des conteneurs

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

Invalidation des itérateurs

Un point critique lors de la manipulation des conteneurs est l’invalidation des itérateurs. Certaines opérations rendent les itérateurs existants invalides :

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;
}

Itérateurs

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.

Interface de base

Tout itérateur fournit au minimum les opérations suivantes :

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.

Couples begin / end

Chaque conteneur fournit :

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.

Catégories d’itérateurs

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 positions

La 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.

Opérations utilitaires sur les itérateurs

L’en-tête <iterator> fournit des fonctions utiles :

#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 = 2

Algorithmes

L’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.

Algorithmes de recherche

#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; }); // false

Algorithmes de tri

std::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()).

Algorithmes de transformation

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}

Algorithmes de réduction

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 = 55

Algorithmes de modification

std::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());

Algorithmes sur les ensembles triés

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 > 3

std::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.

Principe de conception

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.

Concevoir ses propres classes itérables

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 !=.

Exemple : une grille 2D avec itérateur

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);

Rendre l’itérateur complet : iterator_traits

L’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; });

Itérateur sur une vue ou une transformation

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); // 15

Cet 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).

Threads et parallélisme

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.

Notion de thread

Processus avec threads : mémoire partagée et piles séparées

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.

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.

Création d’un thread en C++

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 :

Exemple d’exécution parallèle

Timeline d’exécution parallèle avec join()

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 :

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.

Passage d’arguments aux threads

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).

Threads multiples et parallélisme réel

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 machine

Un 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.

Mémoire partagée et conditions de course

Race condition sans mutex vs exécution correcte avec mutex

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 :

  1. Lire la valeur actuelle de counter depuis la mémoire dans un registre.
  2. Incrémenter la valeur dans le registre.
  3. Écrire le résultat dans la mémoire.

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.

Synchronisation et sections critiques

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.

Variables atomiques

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.

Coût et limites du multithreading

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 :

Programmation générique, template

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.

Principe général des templates

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 = float

Lorsque 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.

Templates de fonctions

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);    // float

Si 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.

Templates de classes

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.

Exemples pour des vecteurs

En informatique graphique, les templates sont très utilisés pour :

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);

Paramètres templates non typés

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 compilation

Ici, 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.

Spécialisation de templates

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.

Principes de compilation: duck typing, instanciation et fichiers d’en-tête

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.

Duck typing statique

Duck typing statique : le type doit fournir les opérations utilisées

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 compilation

L’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.

Instanciation des templates

Instanciation de templates : un template, plusieurs versions compilées

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.

Conséquence importante : code visible à la compilation

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.

Templates et fichiers d’en-tête (.hpp)

Contrairement aux fonctions et classes classiques, le corps des templates doit être visible partout où ils sont utilisés. C’est pourquoi :

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.

Pourquoi les templates ne peuvent pas être compilés séparément

Dans un code classique :

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 :

Exceptions et cas particuliers

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.

Meta-programmation statique

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.

Principe général

L’idée centrale est la suivante :

utiliser le compilateur comme un moteur de calcul.

Les valeurs produites par la méta-programmation :

Méta-programmation avec paramètres templates entiers

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 :

constexpr : calculs évalués par le compilateur

Depuis 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
}

Calculs récursifs à la compilation

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.

Méta-programmation par templates (forme historique)

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 compilation

Cette technique est plus complexe et moins lisible, mais elle est importante historiquement et encore présente dans certaines bibliothèques.

Cas d’usage typiques

La méta-programmation statique est utilisée pour :

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.

Limites et précautions

Déduction de types dans les templates

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.

Principe général de la déduction

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 float

Ici, le compilateur déduit T automatiquement à partir des arguments passés à la fonction.

Limites de la déduction automatique

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>(); // OK

Exemple problématique : produit scalaire générique

Considé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 :

Pourquoi la déduction échoue ici

La déduction échoue car :

Le compilateur ne peut déduire un paramètre template que s’il est directement lié aux types des arguments.

Exposer les paramètres template dans les types

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 automatiquement

Ici :

Accès aux types internes : typename

Lorsqu’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_type

Sans typename, le compilateur ne peut pas savoir si value_type est un type ou une valeur statique.

Déduction partielle et paramètres par défaut

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.

Déduction avec 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é.

Spécialisation des templates

Spécialisation de templates : du générique au plus spécifique

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++.

Principe général

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<<.

Spécialisation complète d’un template

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écialisation

Le compilateur choisit automatiquement la version la plus spécifique disponible.

Spécialisation de templates de fonctions

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.

Spécialisation partielle (templates de classes)

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.

Spécialisation partielle avec types pointeurs

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;  // true

Ce type de spécialisation est largement utilisé dans la STL (std::is_pointer, std::is_integral, etc.).

Spécialisation totale (ou complète)

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 :

Exemple : vecteur générique de taille fixe

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.

Spécialisation totale pour un vecteur 2D

Supposons que l’on souhaite un traitement particulier pour les vecteurs 2D, par exemple :

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 :

Utilisation

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;

Le choix est fait à la compilation, sans aucun test à l’exécution.

Spécialisation totale pour un type et une taille précis

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 :

Comparaison avec la spécialisation partielle

Priorité entre spécialisation et surcharge

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.

Étape 1 : résolution de surcharge (overloading)

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.

Étape 2 : sélection du template

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 = double

Ici, le template est sélectionné car aucune fonction classique ne correspond.

Étape 3 : spécialisation du template

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 bool

Ré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.

Cas subtil : spécialisation vs surcharge

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 :

  1. le compilateur voit une fonction non-template display(int)prioritaire,
  2. le template n’est même pas considéré,
  3. la spécialisation du template est ignorée.

Une spécialisation ne peut jamais battre une surcharge non-template.

Pourquoi ce comportement ?

Parce que :

C++ impose donc une hiérarchie stricte.

Ordre de résolution

Lors d’un appel de fonction :

  1. Sélection des fonctions candidates (nom, portée).

  2. Résolution de surcharge :

  3. Si un template est choisi :

  4. 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.

Alias

Alias de types dans les templates (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 :

Alias avec 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.

Alias avec 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.

Alias dans une classe template

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 :

Ces alias rendent la classe auto-descriptive et facilitent son utilisation dans du code générique.

Utilisation des alias dans des fonctions templates

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 :

Alias et types dépendants (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_type

Sans typename, le compilateur ne peut pas savoir si value_type est un type ou une valeur statique.

Alias templates (alias paramétrés)

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 :

Alias et cohérence des interfaces génériques

Les alias sont largement utilisés dans la STL :

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 << " ";
}

Méthodologies de développement et bonnes pratiques

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).

Qualité de code : objectifs concrets

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.

Principes généraux : KISS, DRY, YAGNI

KISS – Keep It Simple, Stupid

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;
}

DRY – Don’t Repeat Yourself

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 double

YAGNI – You Aren’t Gonna Need It

Ne 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; };

Invariants, assertions et contrat de fonction

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.

Notion de contrat

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.

Les trois notions clés du contrat

Contrat de fonction : préconditions, postconditions, invariant

On distingue trois types de règles complémentaires.

1. Préconditions

Une précondition est une condition qui doit être vraie avant l’appel d’une fonction.

Exemples :

2. Postconditions

Une postcondition est une condition qui doit être vraie après l’exécution de la fonction.

Exemples :

3. Invariants

Un invariant est une propriété qui doit être toujours vraie pour un objet valide.

Exemples :

Illustration conceptuelle : pile (stack)

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.

Assertions à l’exécution (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 :

À quoi servent les assert ?

Les assertions permettent de :

Elles sont donc un outil de développement, pas un mécanisme de gestion d’erreurs utilisateur.

Utilisation de assert

Mode debug vs release

Note: Le programme ne doit jamais dépendre des assertions pour fonctionner correctement.

Assertions à la compilation (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 :

Quand utiliser static_assert ?

Règle générale : préférer les vérifications à la compilation quand c’est possible.

Exemple complet : pile avec invariant et assertions

#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;
    }
};

Synthèse sur les contrats

Alternatives à asserts

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 :

Tests et Test-Driven Development (TDD)

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.

Utilité des tests

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.

Qu’est-ce qu’un bon test ?

Un bon test est :

Grandes catégories de tests

Tests unitaires

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.

Tests d’intégration

Un test d’intégration vérifie l’interaction entre plusieurs composants :

Ils sont plus lents mais plus proches du comportement réel.

Tests de non-régression

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.

Structure d’un test : Arrange / Act / Assert

Un test lisible suit généralement la structure suivante :

  1. Arrange : préparation des données,
  2. Act : appel du code testé,
  3. Assert : vérification du résultat.

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.

Quels cas faut-il tester ?

Pour une fonction donnée, il est recommandé de tester :

  1. le cas nominal (utilisation normale),
  2. les cas limites (bornes, tailles 0 ou 1, valeurs extrêmes),
  3. les cas d’erreur (préconditions violées, entrées invalides).

Tester uniquement le cas nominal est rarement suffisant.

Outil de test minimaliste (sans framework)

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);
    }
}

Exemple guidé : tests unitaires pour clamp

Spécification attendue

La fonction clamp(x, a, b) :

Précondition : a <= b.

Tests

#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.

Test-Driven Development (TDD)

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.

Boucle TDD : Red -> Green -> Refactor

Cycle TDD : Red, Green, Refactor

Le TDD s’organise en cycles courts et itératifs :

  1. Red : écrire un test qui décrit un comportement attendu. Ce test doit échouer (puisque le code correspondant n’existe pas encore). L’échec confirme que le test est pertinent.
  2. Green : écrire le code minimal pour faire passer le test. Pas de généralisation, pas d’optimisation — juste le strict nécessaire. L’objectif est d’atteindre un état fonctionnel le plus vite possible.
  3. Refactor : améliorer la structure du code (noms, duplication, organisation) sans changer son comportement. Les tests existants garantissent que le refactoring n’introduit pas de régression.

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.

Intérêts du TDD

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).

Exemple TDD : normalisation d’un vecteur 3D

Spécification

Étape 1 : test (Red)

#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);
}

Étape 2 : implémentation minimale (Green)

#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};
}

Étape 3 : refactor (Refactor)

Ensuite, on peut :

Conclusion sur les tests et le TDD

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.

Test des cas invalides

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.

Exemple : tester un cas invalide détecté par assert

On 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 :

Exemple : tester un cas invalide avec gestion d’erreur explicite

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 :

Création des tests

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 :

Gestion des erreurs : principes et méthodologie

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.

Nécessité d’une gestion explicite

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.

Deux grandes catégories d’erreurs

Erreurs de programmation vs erreurs d’exécution

La première étape consiste à distinguer la nature de l’erreur.

1. Erreurs de programmation (bugs)

Ce sont des situations qui ne devraient jamais arriver si le code est correctement utilisé.

Exemples :

Ces erreurs indiquent un bug.

Traitement recommandé :

assert(index < data.size() && "index hors limites");

Ces erreurs ne sont généralement pas récupérables.

2. Erreurs d’usage ou d’environnement

Ce sont des situations prévisibles, même si le code est correct.

Exemples :

Ces erreurs doivent être signalées à l’appelant.

Traitement recommandé :

Stratégies de gestion des erreurs en C++

Le choix d’une stratégie dépend :

1. Exceptions

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 :

Inconvénients :

À utiliser avec discipline : documenter les exceptions levées, et ne jamais utiliser les exceptions pour du contrôle de flux normal.

2. Codes de retour

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 :

3. Types résultats (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.

Exemple complet : API robuste avec type résultat

#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());

Lien avec le contrat et les tests

Bonnes pratiques pour la conception d’API

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.

Objectifs d’une bonne API

Une API bien conçue doit être :

Rendre les erreurs explicites dans l’API

Une API doit indiquer clairement comment les erreurs sont signalées.

Mauvais exemple (erreur silencieuse)

float normalize(vec3 const& v); // que se passe-t-il si v est nul ?

Ici :

Exemple avec type résultat explicite

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.

Exemple avec précondition explicite (erreur de programmation)

vec3 normalize(vec3 const& v); // précondition : norm(v) > 0

Ici :

Choisir explicitement si l’erreur est récupérable ou non.

Préférer des types expressifs

Les types doivent porter le sens, pas seulement les valeurs.

À éviter : paramètres ambigus

void load(int mode); // que signifie mode ?

L’API permet des valeurs invalides (mode = 42).

Préférer : types forts et explicites

enum class LoadMode { Fast, Safe };
void load(LoadMode mode);

Utilisation :

load(LoadMode::Fast);

Avantages :

Autre exemple : bool ambigu vs type dédié

void draw(bool wireframe); // que signifie true ?

Meilleur design :

enum class RenderMode { Solid, Wireframe };
void draw(RenderMode mode);

Limiter les états invalides

Une bonne API rend les états invalides impossibles ou difficiles à représenter.

Exemple problématique : état partiellement valide

struct Image {
    unsigned char* data;
    int width;
    int height;
};

Ici, rien n’empêche :

Meilleur exemple : invariant établi par le constructeur

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 :

Séparer interface et implémentation

L’API doit exposer ce que fait le code, pas comment il le fait.

Header (.hpp) : interface

// image.hpp
class Image {
public:
    Image(int w, int h);
    void clear();
    void save(const std::string& filename) const;
};

Source (.cpp) : implémentation

// image.cpp
#include "image.hpp"

void Image::clear()
{
    // détails internes invisibles pour l'utilisateur
}

Avantages :

Éviter les effets de bord cachés

Une fonction ne doit pas modifier des états globaux de manière inattendue.

Mauvais exemple

void render()
{
    global_state.counter++; // effet de bord caché
}

Meilleur exemple

void render(RenderContext& ctx)
{
    ctx.counter++;
}

Les dépendances sont explicites et testables.

Règles pratiques de conception d’API

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.

Du transistor au programme C++

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.

Le transistor : un interrupteur commandé

Principe

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é :

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.

Symboles des transistors MOSFET

Historique : du tube à vide au transistor

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.

Physique du transistor MOSFET

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.

Dopage du silicium

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.

Structure d’un MOSFET

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.

Fonctionnement : l’inversion du canal

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.

La commutation entre ces deux états est ce qui permet de représenter l’information binaire.

Intérêt de l’oxyde

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.

Coupe transversale d’un transistor NMOS

Limites physiques à l’échelle nanométrique

À 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.

Échelle et ordres de grandeur

Ordres de grandeur :

Des transistors aux portes logiques

Construire de la logique avec des interrupteurs

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.

La porte NOT (inverseur)

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 :

Entrée Sortie
0 1
1 0

La porte AND

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

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

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 ^.

Organisation des transistors dans les portes logiques NOT et NAND

Le lien avec le chapitre sur l’encodage

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.

Du calcul logique au calcul arithmétique

L’addition

L’opération a + b en C++ se traduit en un circuit construit à partir de portes logiques.

Le demi-additionneur (half adder)

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.

L’additionneur complet (full adder)

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.

L’additionneur N bits

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.

Demi-additionneur, additionneur complet et additionneur N bits

Ainsi, une simple addition a + b en C++ mobilise environ un millier de transistors travaillant en concert.

La soustraction

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.

Comparaisons

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.

Multiplication et division

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.

L’ALU : l’unité qui regroupe tout

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.

Instructions vectorielles (SIMD)

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.

Scalaire vs SIMD

La mémoire : stocker des bits avec des transistors

Le problème du stockage

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.

La bascule : mémoriser un bit avec 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.

Cellules memoire SRAM (6T) et DRAM (1T1C)

SRAM : la mémoire rapide (registres et caches)

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.

DRAM : la mémoire principale (RAM)

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.

Mémoire flash : le stockage persistant

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.

Synthèse des types de mémoire

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 cache : combler l’écart entre CPU et mémoire

Le problème de la latence mémoire

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.

La solution : une hiérarchie de caches

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
Architecture CPU et hierarchie de caches

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.

Impact sur le code C++

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.

Le fil rouge : du int a = 42; aux transistors

Le chemin complet d’une ligne de C++ jusqu’au matériel :

int a = 5;
int b = 3;
int c = a + b;
  1. 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”.

  2. 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.

  3. 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).

  4. Le résultat 8 (00001000) est stocké dans un registre (bascules SRAM, 6 transistors par bit × 32 bits = 192 transistors pour un seul int).

  5. 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.

Du code C++ aux transistors