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
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.
// --- 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
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.
Qui est valeur, qui est référence ?
🟢 Types valeur
int,long,short,bytefloat,double,decimalbool,charDateTime,DateOnly,TimeSpan,Guid- tout
structet toutenum - 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, lesdelegate record(saufrecord struct)object,dynamic
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)
« 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();
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 techniquesUtilise 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
! 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 | == compare | Comment obtenir l'égalité par contenu |
|---|---|---|
class | les adresses (même objet ?) | Redéfinir Equals et GetHashCode, ou passer en record |
record / record struct | le contenu ✅ | Rien à faire, c'est offert |
struct | rien : == n'existe pas par défaut | Equals fonctionne (champ par champ) ; définis == si tu en as besoin |
string | le contenu ✅ | Rien à faire (opérateur surchargé) |
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
- 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(ourecord)
- 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");
}
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.
// 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
| Type | default | Conséquence pratique |
|---|---|---|
| Numériques | 0 | Un âge non renseigné vaut 0, pas « inconnu » → utilise int? |
bool | false | Nomme tes booléens en positif : EstActif, pas PasDesactive |
DateTime | 01/01/0001 | Cette date bizarre en base = champ jamais renseigné |
Guid | 00000000-... | Un « Guid vide » signale un identifiant oublié |
| Types référence | null | D'où l'intérêt des types nullables activés |
struct | tous ses champs à leur défaut | Un struct peut exister sans passer par ton constructeur |
Vérifie que c'est passé
🎯 Quiz — 5 questions
1. var b = a; où 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…
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 ?
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 ?
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
int[] : valeur ou référence ?string se comporte comme une valeur ?record. Sinon, Equals + GetHashCode, les deux.object. Coûteux en boucle ; les génériques l'évitent.- Type valeur = copie · type référence = partage. C'est la seule différence, mais elle explique tout.
- C# passe toujours une copie de la variable : pour un objet, une copie de l'adresse.
- Modifier l'objet se voit dehors ; réaffecter la variable non (sauf
ref). stringest immuable : réaffecte, ou utiliseStringBuilderen boucle.- Égalité :
recordpour le contenu,classpour l'identité. classpar défaut ;readonly structseulement pour de petites valeurs mesurées.