Accueil › Les fondations › Chapitre 3

Types, mémoire, valeur vs référence

« J'ai modifié l'objet dans la méthode et ça n'a rien changé. » « J'ai modifié une liste et l'autre variable a changé aussi ! » Les deux phrases décrivent le même malentendu, et ce chapitre le règle définitivement.

Deux familles de types, une seule différence

L'image

Type valeur = une photocopie. Tu passes une photocopie du document à un collègue : il gribouille dessus, ton original est intact.

Type référence = un lien vers un document partagé. Tu envoies le lien : il modifie le document, tu vois ses modifications. Il n'y a qu'un seul document.

La démonstration en 10 lignes
// --- type VALEUR (int) ---
int a = 1;
int b = a;          // photocopie
b = 99;
Console.WriteLine(a);       // 1  ← a n'a pas bougé

// --- type RÉFÉRENCE (une classe) ---
var l1 = new List<string> { "x" };
var l2 = l1;        // lien vers le MÊME objet
l2.Add("y");
Console.WriteLine(l1.Count);   // 2  ← surprise : l1 a changé aussi
PILE (stack) rapide · une par appel de méthode int a = 1 valeur int b = 99 l1 = @1A3F l2 = @1A3F TAS (heap) grand · nettoyé par le ramasse-miettes @1A3F List<string> ["x", "y"] un seul objet, deux liens les variables de type référence ne contiennent qu'une adresse
Modifier b ne touche pas a (deux cases distinctes). Modifier ce que désigne l2 change ce que voit l1 (une seule case pointée deux fois).

🧪 À toi d'essayer

Clique pour exécuter mentalement les deux scénarios et voir la mémoire évoluer.

Choisis une étape…

Qui est valeur, qui est référence ?

🟢 Types valeur

  • int, long, short, byte
  • float, double, decimal
  • bool, char
  • DateTime, DateOnly, TimeSpan, Guid
  • tout struct et tout enum
  • les tuples (int, string)

🔵 Types référence

  • toute class (y compris les tiennes)
  • string ⚠️ (mais immuable, voir plus bas)
  • les tableaux int[] — même d'entiers !
  • List<T>, Dictionary<K,V>, toutes les collections
  • les interface, les delegate
  • record (sauf record struct)
  • object, dynamic
Piège nº 1 : int[] est un type référence

Un tableau d'entiers contient des valeurs, mais le tableau lui-même est un objet du tas. Passer un int[] à une méthode qui le modifie change le tableau de l'appelant :

void Reinitialiser(int[] nombres) => nombres[0] = 0;

var t = new[] { 5, 6, 7 };
Reinitialiser(t);
Console.WriteLine(t[0]);   // 0 — le tableau appelant a bien été modifié

Ce qui se passe vraiment à l'appel d'une méthode

Règle unique, qui explique tout : C# passe toujours une copie de la variable. Pour un type valeur, c'est une copie de la donnée. Pour un type référence, c'est une copie de l'adresse — donc les deux adresses désignent le même objet.

class Client { public string Nom = "Ada"; }

void Renommer(Client c)      => c.Nom = "Bob";      // modifie l'objet pointé  → visible dehors
void Remplacer(Client c)     => c = new Client();   // remplace la copie d'adresse → invisible dehors
void RemplacerRef(ref Client c) => c = new Client();// remplace la variable elle-même → visible dehors

var client = new Client();
Renommer(client);      Console.WriteLine(client.Nom);   // Bob
Remplacer(client);     Console.WriteLine(client.Nom);   // Bob (rien n'a changé !)
RemplacerRef(ref client); Console.WriteLine(client.Nom);// Ada (nouvel objet)
La question à se poser

« Est-ce que je modifie l'objet (ses propriétés) ou la variable (à quoi elle pointe) ? » Modifier l'objet se voit partout. Réaffecter la variable ne se voit que localement, sauf ref/out.

Le cas string : référence mais immuable

string est un type référence, pourtant il se comporte comme une valeur. La raison : il est immuable. Aucune méthode ne modifie une chaîne existante ; elles en renvoient une nouvelle.

var s = "bonjour";
s.ToUpper();                     // ← ne fait RIEN d'utile : le résultat est jeté
Console.WriteLine(s);            // bonjour

s = s.ToUpper();                 // ← il faut réaffecter
Console.WriteLine(s);            // BONJOUR

// Conséquence en boucle : chaque += crée une NOUVELLE chaîne
var mauvais = "";
for (int i = 0; i < 10_000; i++) mauvais += i;      // 10 000 objets alloués 😱

var bon = new System.Text.StringBuilder();          // ← un seul tampon réutilisé
for (int i = 0; i < 10_000; i++) bon.Append(i);
var resultat = bon.ToString();
Sous le capot : interning et comparaison

Les littéraux de chaîne identiques d'un même assembly sont internés : ils partagent un unique objet. C'est pourquoi "abc" == "abc" peut être vrai par référence. Mais ne compte jamais là-dessus : == sur string compare le contenu, car l'opérateur est surchargé. Pour comparer sans tenir compte de la casse, ne bricole pas avec ToLower() (qui alloue) :

a.Equals(b, StringComparison.OrdinalIgnoreCase)   // rapide, sans allocation, sans surprise culturelle
string.Equals(a, b, StringComparison.Ordinal)     // le défaut à préférer pour des identifiants techniques

Utilise Ordinal pour les données techniques (clés, codes) et CurrentCulture uniquement pour ce qui est affiché à un humain — sinon le tri d'un utilisateur turc te réserve des surprises avec la lettre « i ».

null : l'absence, et comment le compilateur t'aide

null signifie « cette variable ne désigne aucun objet ». Y accéder provoque l'erreur la plus célèbre de l'informatique : NullReferenceException.

// Avec <Nullable>enable</Nullable> dans le .csproj (activé par défaut sur les nouveaux projets) :
string nom  = null;    // ⚠️ avertissement CS8600 : string ne devrait pas être null
string? sur = null;    // ✅ explicitement « peut être null »

Console.WriteLine(sur.Length);      // ⚠️ CS8602 : déréférencement possible d'un null

// Les trois outils à connaître :
Console.WriteLine(sur?.Length);              // ?. → null au lieu d'exploser
Console.WriteLine(sur ?? "valeur par défaut"); // ?? → si null, prends ça
if (sur is not null) Console.WriteLine(sur.Length);  // vérification explicite
Le ! qui fait taire le compilateur

sur!.Length signifie « fais-moi confiance, ce n'est pas null ». Aucune vérification n'est générée : si tu te trompes, retour du NullReferenceException. À n'utiliser que quand tu sais quelque chose que le compilateur ne peut pas savoir — et de préférence avec un commentaire qui l'explique. Détails au chapitre 9.

Égalité : trois questions différentes

class ClientClasse  { public string Nom = ""; }
record ClientRecord(string Nom);
struct Point { public int X, Y; }

var c1 = new ClientClasse { Nom = "Ada" };
var c2 = new ClientClasse { Nom = "Ada" };
Console.WriteLine(c1 == c2);        // False ← deux objets différents (comparaison d'adresses)

var r1 = new ClientRecord("Ada");
var r2 = new ClientRecord("Ada");
Console.WriteLine(r1 == r2);        // True  ← record : comparaison par contenu, offerte

Console.WriteLine(new Point { X = 1 }.Equals(new Point { X = 1 }));  // True (struct : contenu)

Console.WriteLine(ReferenceEquals(r1, r2));   // False ← « est-ce le même objet ? »
Type== compareComment obtenir l'égalité par contenu
classles adresses (même objet ?)Redéfinir Equals et GetHashCode, ou passer en record
record / record structle contenu ✅Rien à faire, c'est offert
structrien : == n'existe pas par défautEquals fonctionne (champ par champ) ; définis == si tu en as besoin
stringle contenu ✅Rien à faire (opérateur surchargé)
Piège : Equals sans GetHashCode

Si tu redéfinis Equals sans GetHashCode, ton type devient invisible dans un Dictionary ou un HashSet : tu ajoutes un élément, tu le cherches, il n'y est pas. Le compilateur t'avertit. La solution la plus simple en 2026 : utilise un record.

struct ou class ? La règle honnête

Utilise class (99 % des cas)
  • Ton objet a une identité (un client, une commande)
  • Il est modifiable, ou volumineux
  • Tu veux de l'héritage ou du polymorphisme
  • Tu hésites → class (ou record)
struct seulement si TOUT est vrai
  • Petit (≤ 16 octets environ, soit 2–4 champs)
  • Logiquement une valeur unique (un point, un montant, une couleur)
  • Immuable (readonly struct)
  • Créé en très grand nombre, et tu as mesuré que ça compte
// Le bon struct : petit, immuable, sans identité
public readonly record struct Argent(decimal Montant, string Devise)
{
    public Argent Plus(Argent autre) => Devise == autre.Devise
        ? new Argent(Montant + autre.Montant, Devise)
        : throw new InvalidOperationException("Devises différentes");
}
Piège : le struct modifiable dans une collection
struct Compteur { public int Valeur; }

var liste = new List<Compteur> { new Compteur() };
// liste[0].Valeur++;      ← ne compile même pas : liste[0] renvoie une COPIE

var tableau = new Compteur[1];
tableau[0].Valeur++;       // ← compile et fonctionne (accès direct à l'élément)

Un struct modifiable produit ce genre d'incohérence entre List et tableau : c'est précisément pour cela qu'on les déclare readonly.

Boxing : le coût invisible

Quand un type valeur doit être vu comme un objet, .NET l'emballe dans le tas : c'est le boxing.

int i = 42;
object o = i;          // BOXING : allocation dans le tas
int j = (int)o;        // UNBOXING : déballage, avec vérification de type

// Cas fréquents, souvent involontaires :
object[] melange = { 1, 2, 3 };               // 3 boxings
ArrayList vieux = new(); vieux.Add(42);        // API pré-générique → boxing systématique
string s = $"valeur = {i}";                   // ⚠️ historiquement un boxing, optimisé aujourd'hui

C'est d'ailleurs la raison d'être des génériques : List<int> stocke de vrais int, sans emballage, contrairement à l'antique ArrayList.

Éviter les allocations : ref, in, Span
// in : passage par référence en lecture seule → pas de copie du gros struct
static decimal Distance(in Matrice4x4 m) { /* ... */ }

// Span<T> : une fenêtre sur de la mémoire existante, zéro allocation
ReadOnlySpan<char> texte = "2026-07-30";
var annee = int.Parse(texte[..4]);         // aucun sous-string créé

// stackalloc : tampon sur la pile, libéré automatiquement
Span<byte> tampon = stackalloc byte[256];

Span<T> est un ref struct : il ne peut pas être stocké dans un champ de classe, ni traverser un await, parce qu'il ne doit jamais survivre à la mémoire qu'il regarde. Utile pour le parsing intensif, inutile ailleurs — voir chapitre 19.

Valeurs par défaut

TypedefaultConséquence pratique
Numériques0Un âge non renseigné vaut 0, pas « inconnu » → utilise int?
boolfalseNomme tes booléens en positif : EstActif, pas PasDesactive
DateTime01/01/0001Cette date bizarre en base = champ jamais renseigné
Guid00000000-...Un « Guid vide » signale un identifiant oublié
Types référencenullD'où l'intérêt des types nullables activés
structtous ses champs à leur défautUn struct peut exister sans passer par ton constructeur

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. var b = a;a est une List<int>. Combien de listes existent en mémoire ?

List<T> est un type référence : l'affectation copie l'adresse, pas le contenu. Pour une vraie copie : new List<int>(a) ou a.ToList().

2. void F(Client c) => c = new Client(); — après l'appel, la variable de l'appelant…

Réaffecter le paramètre ne touche pas la variable de l'appelant. Il faudrait ref Client c, ou mieux : renvoyer le nouvel objet.

3. s.ToUpper(); seul sur une ligne, sans réaffectation :

string est immuable. Les analyseurs signalent d'ailleurs ce genre d'appel dont le résultat n'est pas utilisé.

4. Tu redéfinis Equals sur une classe utilisée comme clé de Dictionary, mais pas GetHashCode. Que se passe-t-il ?

Le dictionnaire utilise d'abord le hash pour trouver le casier, puis Equals dedans. Deux objets « égaux » avec des hash différents ne se rencontrent jamais.

5. Tu modélises une adresse postale (rue, ville, code postal). struct ou class ?

Trois string = trois références = bien au-delà des 16 octets conseillés, et chaque copie devient coûteuse. Un record donne l'égalité par contenu voulue tout en restant un type référence. (En base, ce sera un type complexe EF Core — chapitre 15.)

Fiches de révision

Valeur vs référence en une phrase ?
Valeur = la variable contient la donnée (photocopie). Référence = la variable contient l'adresse (lien partagé).
Pile ou tas ?
Pile = variables locales, vidée au retour de méthode. Tas = objets, nettoyé par le GC.
int[] : valeur ou référence ?
Référence ! Un tableau est un objet, même s'il contient des valeurs.
Pourquoi string se comporte comme une valeur ?
Parce qu'il est immuable : aucune méthode ne le modifie, elles renvoient une nouvelle chaîne.
Égalité par contenu sans effort ?
Utilise un record. Sinon, Equals + GetHashCode, les deux.
Boxing ?
Emballer un type valeur dans le tas pour le voir comme object. Coûteux en boucle ; les génériques l'évitent.
À retenir