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 :
- Procédural, hérité du C, pour une approche classique fondée sur des fonctions et des structures de contrôle.
- Orienté objet, introduit avec les classes, l’encapsulation, l’héritage et le polymorphisme, facilitant la conception modulaire de logiciels complexes.
- Générique, grâce aux templates (généricité paramétrée par type), qui permettent d’écrire du code réutilisable et indépendant des types.
- Fonctionnel, de plus en plus présent depuis C++11 avec les lambdas (fonctions anonymes) et les algorithmes de la bibliothèque standard.

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 :

Remarques importantes

  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

    [Attention] Pour des types simples, il est 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.

  3. Variables non initialisées

    En C++, les variables fondamentales ne sont pas initialisées par défaut.

    int a; // contient une valeur indéfinie

    ⇒ Pour éviter les comportements indéterminés, il est conseillé d’initialiser systématiquement vos variables :

    int a = 0;

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é

Bonnes pratiques :

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

[Attention] : 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

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

Résumé

Conditionnelles et boucles

if / else

Structure générale :

if (condition) {
    // instructions si la condition est vraie
} else {
    // instructions si la condition est fausse
}

[Attention] 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
}

[Attention] 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)).

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.

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

Remarques importantes

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

[Attention] 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;
}

Résumé

Passage d’arguments: copie, 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 the 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 the 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

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

Remarques importantes

À 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

 main.cpp   vec3.cpp   utils.cpp
     ↓         ↓          ↓
  (compilateur) (compilateur) (compilateur)
     ↓         ↓          ↓
 main.o    vec3.o     utils.o
     ↓         ↓          ↓
  [linker / éditeur de liens]
             ↓
     programme exécutable

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.

Remarques importantes :

Encodage des entiers

Représentation binaire

Un entier est représenté en binaire :

Exemple :

Décimal Binaire (8 bits)
0 00000000
1 00000001
2 00000010
3 00000011
4 00000100
156 10011100

Un entier peut être représenté sur plusieurs octets :


Entiers non signés

Un unsigned int sur 4 octets (32 bits) code des valeurs de 0 à 2^32 - 1 = 4 294 967 295.

Exemple en hexadécimal (représentation pratique des octets) :

Rappel :

Entiers signés et complément à deux

Les entiers signés utilisent le bit le plus à gauche (MSB) pour coder le signe :

Méthode d’encodage : complément à deux.

Exemple sur 8 bits :

  00000101 = +5
Inverse → 11111010
Ajout +1 → 11111011 = -5

Conséquence :

Exemple pratique

Prenons l’entier signé encodé sur 2 octets :

C4 8D (hexadécimal)
= 11000100 10001101 (binaire)

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 × (1 + mantisse) × 2exposant − biais

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

[Attention] Propriétés importantes :

if (std::abs(a - b) < 1e-6) { ... }

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. Little Endian (Intel x86, ARM en mode par défaut)

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

Pourquoi est-ce important ?

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 = 1e12;
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.

Remarques importantes

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

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

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

Résumé

Pointeurs

Notion de stockage et d’adressage en mémoire

La mémoire d’un ordinateur peut être vue comme un grand tableau linéaire de cases.

On peut donc imaginer la mémoire comme une succession de cases numérotées :

Adresse   Contenu
1000      10101010
1001      00001111
1002      11110000
1003      01010101
...

Ici :

Adresses et variables

Quand on déclare une variable en C++ :

int a = 42;
Adresse   Contenu
1000      00101010   (0x2A)
1001      00000000
1002      00000000
1003      00000000

Ainsi :

Taille et alignement

Remarque: La taille peut varier selon l’architecture, mais 1 octet = 8 bits est garanti.

Par souci de performance, le compilateur peut introduire du padding (remplissage avec des 0) pour que certaines variables commencent à des adresses multiples de 2, 4 ou 8. Cela facilite l’accès mémoire pour le processeur.

Importance de l’adresse

L’adresse mémoire est ce qui permet :

Exemple d’analogie

On peut comparer la mémoire :

Pour accéder à une donnée, le processeur doit connaître l’adresse exacte.

Résumé

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

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.

Résumé des mécanismes

Schéma (simplifié en ASCII) :

main:
 a = 5        (adresse 1000)

Appel increment(&a) :
    copie de l'adresse 1000 dans p

increment:
 p = 1000
 *p → valeur stockée à l'adresse 1000 → 5
 *p = 6   (modifie la mémoire partagée avec a)

Bonnes pratiques avec les 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
}

Résumé

Cas des tableaux contigus

Tableaux C

En C et C++, un tableau est toujours stocké en mémoire comme une suite contiguë d’octets. Cela signifie que les éléments se suivent les uns après les autres, sans espace entre eux.

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

Le nom d’un tableau (tab) est automatiquement converti en pointeur vers son premier élément (&tab[0]). On peut alors utiliser l’arithmétique des pointeurs :

Cela revient exactement à écrire tab[N].

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.


En C/C++, l’expression p + N ne signifie pas “ajouter N octets”, mais “aller au N-ième élément à partir de p”.

C’est le compilateur qui traduit l’opération en calcul d’adresse, et c’est le processeur qui fait l’addition lors de l’exécution.

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
}

Résumé

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)

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

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 et désallocation mémoire

L’allocation mémoire consiste à réserver dynamiquement une zone de mémoire pendant l’exécution du programme, et la désallocation consiste à la libérer lorsqu’elle n’est plus nécessaire. Cette gestion dynamique est indispensable lorsque la taille des données n’est pas connue à la compilation ou lorsque leur durée de vie dépasse un bloc local.

En C et en C++, la mémoire dynamique est stockée dans une zone appelée le tas (heap), par opposition à la pile (stack) utilisée pour les variables locales.

Pile (stack) vs tas (heap)

Variables sur la pile :

void f() {
    int x = 10; // sur la pile
}

Mémoire dynamique sur le tas :

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

Exemple classique d’erreur : fuite mémoire

void f() {
    int* p = new int(10);
    // oubli de delete
}

À chaque appel de f, la mémoire est allouée mais jamais libérée : fuite mémoire.

Double libération (dangereux)

int* p = new int(5);
delete p;
delete p; // ERREUR : double free

Cela provoque un comportement indéfini.

Pointeur nul après libération

Bonne pratique :

int* p = new int(5);
delete p;
p = nullptr;

Cela évite l’accès à un pointeur libéré (dangling pointer).

Résumé

La gestion manuelle de la mémoire est puissante mais dangereuse. En C++, elle doit être limitée aux cas nécessaires et remplacée autant que possible par des abstractions sûres.

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 :

Point clé à retenir

void* est un pointeur sans information de type : il offre une grande flexibilité, mais aucune sécurité. Toute utilisation correcte repose sur des conversions explicites et la rigueur du programmeur.

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

Passage par valeur (par défaut en C/C++)

#include <iostream>

int ma_fonction(int b) {
    b = b + 2;  // modifie la copie locale
    return b;
}

int main() {
    int a = 5;
    int c = ma_fonction(a);
    std::cout << a << ", " << c << std::endl; // a=5, c=7
}

Ici :

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

Résumé

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

La pile (stack) vs le tas (heap)

Caractéristique Pile (stack) Tas (heap)
Allocation Automatique Manuelle (ou contrôlée par objets)
Durée de vie Limitée au bloc courant Jusqu’à libération explicite
Taille maximale Limitée (quelques Mo) Très grande (plusieurs Go)
Gestion Par le compilateur Par le programmeur
Exemple int a; ou int tab[10]; new int; ou new int[n];

Sur la plupart des systèmes, la pile a une taille limitée (~8 Mo par défaut), alors que le tas peut utiliser plusieurs gigaoctets. L’allocation dynamique permet donc de créer des structures volumineuses ou de tailles variables à l’exécution.

Exemple : durée de vie limitée avec variables automatiques

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

Exemple : durée de vie prolongée avec allocation dynamique

#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 d’un tableau

#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

  1. Fuite mémoire :

    void f() {
        int* p = new int(10);
        // oubli de delete → fuite mémoire
    }

    → la mémoire reste occupée tant que le programme tourne.

  2. Double libération :

    int* p = new int(5);
    delete p;
    delete p; // erreur : libération double
  3. Utilisation après libération :

    int* p = new int(5);
    delete p;
    std::cout << *p; // comportement indéfini

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


Bonnes pratiques modernes

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)

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
Type Copiable Partage de ressource Destruction Cas d’usage typique
std::unique_ptr<T> Non Non Automatique, dès que le pointeur sort de portée Possession exclusive (ex. composant interne d’un objet)
std::shared_ptr<T> Oui Oui (compteur de références) Automatique, quand le dernier pointeur est détruit Ressources partagées entre plusieurs objets ou fonctions

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

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.

Bonnes pratiques

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)

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"

Bonnes pratiques

Opérateurs

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;

Bonnes pratiques

La surcharge d’opérateurs permet d’écrire du code plus lisible et plus expressif, 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.

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

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.

Bonnes pratiques

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.

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.

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.

Bonnes pratiques

Le polymorphisme permet ainsi de concevoir des systèmes extensibles où de nouveaux types peuvent être ajoutés sans modifier le code existant, en particulier lorsqu’il s’agit de manipuler des collections d’objets variés.

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.

Bonnes pratiques

Mot clé : static

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

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

Bonnes pratiques

Idée clé à retenir

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

Bonnes pratiques

Si tu veux, je peux aussi te proposer une mini-convention d’organisation “type projet” (ex. namespace cgp / namespace csc43043, structure fichiers, exposer uniquement include/ vs src/) pour rendre l’ensemble homogène avec les chapitres sur l’organisation des fichiers.

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.

Notion de thread

Un thread est un fil d’exécution indépendant à l’intérieur d’un même programme.

Tous les threads d’un même programme :

(Petit rappel : en C++ on manipule souvent les threads via la classe std::thread fournie dans <thread>.)

Création d’un thread en C++

Depuis C++11, la bibliothèque standard fournit std::thread.

(std::thread : classe qui représente un fil d’exécution et permet de lancer une fonction dans un thread séparé ; définie dans <thread>.)

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

Points importants :

Dans cet exemple :

Exemple d’exécution parallèle

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 :

Passage d’arguments aux threads

Les arguments sont copiés par défaut.

void print(int x) {
    std::cout << x << std::endl;
}

std::thread t(print, 42);
t.join();

En suivant le format générique de passage d’arguments.

std::thread t(fonction, arg1, arg2, arg3, ...);

Pour passer 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();
}

Threads multiples et parallélisme réel

Exemple avec plusieurs threads :

#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 différent.

Mémoire partagée

Les threads partagent la mémoire, ce qui introduit des risques majeurs :

Exemple dangereux :

int counter = 0;

void increment() {
    counter++; // non atomique
}

Si plusieurs threads exécutent increment(), le résultat est imprévisible.

Synchronisation et sections critiques

Une section critique est une zone de code qui ne doit être exécutée que par un seul thread à la fois.

En C++, on utilise std::mutex.

(std::mutex : mutex (verrou) défini dans <mutex> utilisé pour protéger une section critique.)

#include <mutex>

int counter = 0;
std::mutex m;

void increment() {
    std::lock_guard<std::mutex> lock(m);
    counter++;
}

Variables atomiques

Pour des opérations simples, on peut utiliser std::atomic.

#include <atomic>

std::atomic<int> counter(0);

void increment() {
    counter++;
}

Avantages :

Limite :

Coût et limites du multithreading

Créer des threads a un coût :

Trop de threads peut :

Bonne pratique :

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.

Principe général des templates

Un template est un modèle de code qui n’est pas directement compilé. Le compilateur génère automatiquement une version spécialisée du code pour chaque type utilisé.

template <typename T>
T add(T a, T b) {
    return a + b;
}

Le mot-clé typename (ou alternativement class dans ce contexte) introduit un paramètre de type dans la déclaration template <typename T>.

Utilisation :

int a = add(2, 3);           // T = int
float b = add(1.5f, 2.5f);  // T = float

Pour chaque type (int, float), le compilateur génère une fonction différente, avec les mêmes performances qu’un code écrit à la main.

Templates de fonctions

Les templates de fonctions permettent d’écrire des algorithmes génériques sans dupliquer le code.

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.

Templates de classes

Les templates peuvent aussi être utilisés pour définir des classes génériques.

template <typename T>
struct Box {
    T value;

    explicit Box(T v) : value(v) {}
};

Utilisation :

Box<int> a(3);
Box<float> b(2.5f);

Ici, Box<int> et Box<float> sont deux types distincts générés par le compilateur.

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

Un template peut aussi prendre des paramètres non typés, connus à la compilation.

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

Ce principe est utilisé dans std::array<T, N>.

Spécialisation de templates

Il est possible de fournir une implémentation spécifique pour un type donné.

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

La spécialisation permet d’adapter le comportement sans modifier le code générique.

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

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

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.

Résumé des principes clés

Ces règles expliquent à la fois la puissance et la complexité des templates en C++.

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

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.

L’idée clé est la suivante :

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.

Résumé de la priorité (ordre exact)

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.

Règle pratique à retenir

La surcharge choisit la fonction. La spécialisation choisit l’implémentation du template.

Bonnes pratiques

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

Vue matérielle

Ce chapitre propose une vue simplifiée mais cohérente du matériel sous-jacent à l’exécution d’un programme C/C++. L’objectif n’est pas d’entrer dans l’électronique fine, mais de comprendre comment le code est physiquement exécuté, et pourquoi certaines notions (mémoire, cache, alignement, performances) sont cruciales en informatique graphique et scientifique.

Principe du transistor

Le transistor est l’élément fondamental de tout circuit électronique moderne. Un processeur contient aujourd’hui des milliards de transistors, chacun se comportant comme un interrupteur contrôlable électroniquement.

Rôle fondamental

Un transistor peut être vu comme :

On associe classiquement :

Ces deux états permettent de représenter l’information binaire.

Principe physique du transistor

Principe physique du transistor

Le transistor est avant tout un objet physique, dont le fonctionnement repose sur les propriétés électriques de la matière à l’échelle microscopique. Comprendre son principe physique permet de saisir comment un phénomène continu (tensions, champs électriques, électrons) est exploité pour produire une logique discrète (0 / 1).

Le silicium et la conduction électrique

Le matériau central de l’électronique moderne est le silicium, un cristal dont les électrons sont liés aux atomes par des liaisons covalentes. À l’état pur :

Sa conductivité peut cependant être contrôlée grâce au dopage.

Dopage et porteurs de charge

Le dopage consiste à introduire une très faible quantité d’atomes étrangers dans le cristal de silicium.

Ces porteurs de charge sont mobiles sous l’effet d’un champ électrique, ce qui permet le passage du courant.

Jonction PN et contrôle du courant

Lorsqu’une région dopée P est mise en contact avec une région dopée N, il se forme une jonction PN.

À l’interface :

Selon la tension appliquée :

C’est la première brique physique du contrôle électrique.

Le transistor MOSFET : champ électrique plutôt que courant

Les processeurs modernes utilisent presque exclusivement des transistors MOSFET – Metal Oxide Semiconductor Field Effect Transistor.

Contrairement aux anciens transistors, ils sont contrôlés par un champ électrique (une tension), et non par un courant, ce qui réduit considérablement leur consommation.

Un MOSFET est constitué de quatre terminaux principaux :

  1. Source (S) : L’entrée des électrons.
  2. Drain (D) : La sortie des électrons.
  3. Grille (G) : L’électrode de commande.
  4. Substrat (Body) : Le corps du transistor.

L’innovation clé : La Grille est isolée électriquement du canal par une couche d’Oxyde extrêmement fine.

3. L’Analogie du Robinet

Pour comprendre le fonctionnement, visualisez un robinet d’eau :

Composant MOSFET Analogie Robinet Rôle
Source Arrivée d’eau Fournit le courant.
Drain Sortie d’eau Reçoit le courant.
Grille Poignée Contrôle le débit sans toucher l’eau.
Tension Force sur la poignée La commande d’ouverture.
4. La Physique de la Commutation : La Tension de Seuil ()

Le passage du courant n’est pas instantané. Il repose sur un phénomène appelé l’inversion.

5. Les Régimes de Fonctionnement (Mathématique simplifiée)

Le comportement du courant de drain () suit trois régimes selon les tensions appliquées :

  1. Régime de Blocage (Cut-off) :
  1. Régime Linéaire (Ohmique) :
  1. Régime de Saturation :
6. Du phénomène physique au bit logique

En informatique, on abstraite ces comportements complexes pour ne garder que deux états stables :

Cependant, à l’échelle nanométrique actuelle (transistors de quelques nanomètres), des contraintes physiques réapparaissent :

Échelle nanométrique et contraintes physiques

Les transistors actuels mesurent quelques nanomètres. À cette échelle :

Ces contraintes expliquent :

Du transistor à la logique

En combinant plusieurs transistors, on construit :

Exemple conceptuel :

Ainsi, toute instruction C++ (addition, comparaison, saut conditionnel) se traduit ultimement par des commutations de transistors.

Structure de base de la mémoire et des opérations arithmétiques

Principes de la mémoire

La sauvegarde d’un élément en mémoire repose sur une organisation très précise de transistors, différente selon le type de mémoire. Voici une explication progressive, en partant du bit jusqu’aux mémoires utilisées dans un CPU.


Organisation minimale : stocker un bit

Un bit doit :

On notera plusieurs manière de stocker l’information à l’aide de transistors:

1. La mémoire statique (SRAM)

La SRAM (Static Random Access Memory) est utilisée pour :

Temps d’accès typique: 0,3 à 2 ns

Elle est :

Principe général

Un bit de SRAM est stocké à l’aide d’une bascule électronique bistable, réalisée avec des transistors.

Organisation classique :

Fonctionnement :

Lecture :

Écriture :

La SRAM stocke donc l’information sous forme d’un équilibre électrique actif entre transistors.

2. La mémoire dynamique (DRAM)

La DRAM (Dynamic Random Access Memory) constitue la mémoire centrale d’un ordinateur (RAM).

Temps d’accès typique: 50 à 100 ns

Elle est :

Principe général

Un bit de DRAM est stocké sous forme de charge électrique.

Organisation classique :

Fonctionnement :

Lecture :

Rafraîchissement :

La DRAM stocke l’information sous forme de charge passive, d’où la nécessité du rafraîchissement.

3. La mémoire flash

La mémoire flash est une mémoire non volatile utilisée pour :

Elle est :

Temps d’accès typique: 50 à 100 µs (microsecondes) en lecture, 200 µs à quelques ms en écriture

Principe général

Un bit de mémoire flash est stocké grâce à un transistor à grille flottante.

Organisation :

Fonctionnement :

Lecture :

Effacement :

La mémoire flash stocke l’information sous forme de charges piégées physiquement, ce qui explique sa persistance sans alimentation.

Comparaison synthétique

Type de mémoire Volatile Transistors / bit Rafraîchissement Usage principal
SRAM oui ~6 non registres, caches
DRAM oui 1 + 1 condensateur oui mémoire centrale
Flash non 1 (spécifique) non stockage persistant

Principes des opérations arithmétiques

Du code C++ à l’instruction machine

Une opération arithmétique écrite en C++ est une expression abstraite :

c = a + b;

Pour le processeur, cela correspond à une séquence bien définie :

  1. charger a et b depuis la mémoire vers des registres,
  2. activer l’unité arithmétique avec l’opération demandée,
  3. produire un résultat binaire,
  4. stocker le résultat dans un registre ou en mémoire.

Le processeur ne “comprend” jamais les variables ou les types C++ : il ne manipule que des registres, des opcodes, et des bits.

Rôle central de l’ALU

Les opérations arithmétiques et logiques sont réalisées par l’ALU (Arithmetic Logic Unit).

Fonctions principales de l’ALU :

Toutes ces opérations reposent sur :

L’ALU reçoit :

Soustraction, comparaisons et logique interne

Dans l’ALU :

Exemple conceptuel :

if (a < b) { ... }

Matériellement :

Multiplication et division : opérations composées

Contrairement à l’addition, la multiplication et la division :

La multiplication repose sur :

La division :

Opérations sur les nombres flottants

Les calculs sur les flottants sont pris en charge par une unité distincte : la FPU.

Elle réalise :

Ces opérations sont plus coûteuses que celles sur les entiers, mais entièrement gérées par le matériel.

Instructions vectorielles (SIMD)

Les processeurs modernes disposent d’unités vectorielles capables d’appliquer une même opération sur plusieurs données simultanément.

Principe :

C’est une extension directe des opérations arithmétiques de base, utilisée pour :

Ordonnancement et pipeline

Les opérations arithmétiques ne sont pas exécutées isolément :

Ainsi :

Coût réel d’une opération

Dans un programme réel :

Optimiser les performances revient souvent à :

Idée clé à retenir

Les opérations arithmétiques sont des briques matérielles élémentaires, orchestrées par le processeur via l’ALU, la FPU et les unités vectorielles. Le code C++ exprime des calculs logiques, mais leur exécution repose sur l’ordonnancement, le parallélisme et l’accès efficace aux données.

Notion de cache mémoire

Problème fondamental : la latence mémoire

Accéder à la mémoire principale (RAM) est :

Ordre de grandeur :

Sans mécanisme intermédiaire, le CPU passerait son temps à attendre la mémoire.

Principe du cache

Le cache mémoire est une mémoire intermédiaire :

Il stocke des copies de blocs de mémoire récemment utilisés.

Hiérarchie de cache

On distingue généralement :

Chaque niveau agit comme un tampon vers le niveau inférieur.

Localité spatiale et temporelle

Le cache repose sur deux principes fondamentaux :

C’est pourquoi :

sont beaucoup plus performants que des accès aléatoires.

Lien avec la programmation C++

Exemples de code favorables au cache :

for(int i = 0; i < N; ++i)
    sum += array[i];

Exemples défavorables :

for(int i = 0; i < N; ++i)
    sum += array[random_index[i]];

En informatique graphique, ce point est crucial :

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.

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.

Attention :
éliminer toute duplication peut mener à des abstractions inutiles.
Une duplication locale et simple est parfois préférable à une généralisation complexe.

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.

Ce principe est particulièrement important en C++, où : - les templates, - la généricité, - et la méta-programmation

peuvent encourager une complexité excessive trop tôt.

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.

Pourquoi parler 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

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.

Bonnes pratiques avec 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;
    }
};

Résumé

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.

Pourquoi écrire des tests ?

Les tests sont utiles lorsqu’ils permettent de :

Dans un projet réel, les tests sont souvent exécutés automatiquement à chaque modification (intégration continue).

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 vise à transformer le besoin fonctionnel en comportement vérifiable.

Boucle TDD : Red -> Green -> Refactor

  1. Red : écrire un test qui échoue,
  2. Green : écrire le code minimal pour faire passer le test,
  3. Refactor : améliorer le code sans casser les tests.

Cette boucle est répétée fréquemment.

Intérêts du TDD

Le TDD :

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.

Très bien. Voici un cas d’exemple concret, pédagogique, et cohérent avec ton chapitre, montrant comment tester un cas invalide. Tu peux l’insérer juste après le paragraphe “Tester aussi les cas invalides”.

Je te propose deux variantes complémentaires :

  1. cas invalide détecté par assert (erreur de programmation),
  2. cas invalide détecté par retour d’erreur (erreur d’usage).

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.

Pourquoi une gestion explicite des erreurs ?

Sans stratégie claire de gestion des erreurs, on obtient :

Une bonne gestion des erreurs permet :

Deux grandes catégories d’erreurs

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.

float parse_float(std::string const& s)
{
    return std::stof(s); // peut lever une exception
}

Avantages :

Inconvénients :

À utiliser avec discipline, et à documenter clairement.

2. Codes de retour

Approche historique et explicite.

bool read_file(std::string const& name, Data& out);

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

Voici une version enrichie et pédagogique de ta section Bonnes pratiques pour la conception d’API, avec des exemples concrets “mauvais / meilleur” pour chaque principe, tout en restant cohérente avec le reste de 08-methodology.md.

Tu peux remplacer intégralement ta section actuelle par celle-ci.

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

Idée clé à retenir

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.