Bases du langage
Types valeur et référence, nullable, pattern matching, records, tuples.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 18 min
Le C# range chaque type dans l'une de deux familles, et presque tout en découle : ce qu'une affectation copie, ce que == compare, ce qu'un null peut désigner, ce qu'un record apporte de plus qu'une classe. Ce cours les reprend par le bas, pour le jour où un comportement surprend : une modification qui ne remonte pas à l'appelant, deux objets de mêmes valeurs qui ne sont pas égaux, une NullReferenceException sur une variable que le compilateur jurait non nulle.
Types valeur et types référence
Une struct est une valeur : ses champs sont la variable. Une class est une référence : la variable contient l'adresse d'un objet sur le tas géré.
« struct sur la pile, class sur le tas » est une approximation qui tient mal : une struct vit là où vit la variable qui la porte. Variable locale, elle occupe le cadre de pile. Champ d'une classe ou élément d'un int[], elle est incluse dans l'objet ; capturée par une lambda, elle finit dans la classe de fermeture générée — sur le tas dans les deux cas. La pile n'est pas une propriété du type, c'est une propriété de l'emplacement.
Le geste qui le montre : forme.Origine.X = 3 modifie la struct là où elle est, à l'intérieur de l'objet qui la porte. La même idée gouverne l'écriture, sous un autre nom : tableau[0].X = 7 compile sur un Point[], l'indexeur d'un tableau désignant une variable ; le même geste sur une List<Point> ne compile pas, son indexeur étant une propriété dont l'accesseur rend une copie. Les deux vivent pourtant sur le tas : ce qui les sépare n'est pas l'endroit, c'est le fait de désigner une case ou une valeur.
using System;
using System.Collections.Generic;
struct Point
{
public int X;
public int Y;
}
sealed class Forme
{
// Champ d'une classe : le Point est inclus dans l'objet, donc sur le tas.
public Point Origine;
}
static class Emplacement
{
public static void Executer()
{
var forme = new Forme { Origine = new Point { X = 1, Y = 1 } };
forme.Origine.X = 3;
Console.WriteLine(forme.Origine.X); // 3 : ecrit dans l'objet, pas dans une copie
// L'indexeur d'un tableau designe l'emplacement de l'element : on y ecrit
// comme dans une variable.
var tableau = new Point[2];
tableau[0].X = 7;
Console.WriteLine(tableau[0].X); // 7
var liste = new List<Point> { new Point() };
// liste[0].X = 7; // CS1612 : impossible de modifier la valeur de retour
// // de List<Point>.this[int], qui n'est pas une variable.
Console.WriteLine(liste[0].X); // 0
// L'indexeur d'une liste est une propriete : son accesseur rend une
// copie. Il faut la sortir, la modifier, puis la remettre.
var point = liste[0];
point.X = 7;
liste[0] = point;
Console.WriteLine(liste[0].X); // 7
}
}Ce que le type décide, c'est ce qu'une affectation copie. Pour une struct, le runtime recopie les champs un à un — copie superficielle : un champ de type référence reste une référence, et les deux copies désignent le même objet. Pour une classe, il recopie l'adresse : un seul objet, désigné deux fois. Un passage de paramètre étant une affectation, tout le reste en découle.
using System;
struct PointValeur
{
public int X;
public int Y;
}
sealed class PointReference
{
public int X;
public int Y;
}
static class PileEtTas
{
static void Deplacer(PointValeur p) => p.X += 10;
static void Deplacer(PointReference p) => p.X += 10;
static void DeplacerVraiment(ref PointValeur p) => p.X += 10;
public static void Executer()
{
var valeur = new PointValeur { X = 1, Y = 1 };
var reference = new PointReference { X = 1, Y = 1 };
Deplacer(valeur);
Deplacer(reference);
Console.WriteLine(valeur.X); // 1 : la methode a recu une copie des champs
Console.WriteLine(reference.X); // 11 : la methode a recu l'adresse de l'objet
// ref ne deplace pas la donnee sur le tas : il passe la variable
// elle-meme, donc la methode ecrit dans la case de l'appelant.
DeplacerVraiment(ref valeur);
Console.WriteLine(valeur.X); // 11
// Meme regle pour l'affectation : une struct se recopie, une classe non.
var copie = valeur;
var alias = reference;
copie.X = 99;
alias.X = 99;
Console.WriteLine(valeur.X); // 11 : copie est un autre point
Console.WriteLine(reference.X); // 99 : alias designe le meme objet
}
}ref ne change pas où vit la donnée : il passe la variable elle-même, si bien que la méthode écrit dans la case de l'appelant. in fait de même en interdisant l'écriture. L'économie de copie qu'on en attend vaut au passage, et s'arrête là : si la struct n'est pas readonly, le compilateur insère une copie défensive avant chaque appel de membre d'instance à travers le paramètre, faute de pouvoir prouver qu'il ne la mute pas. Seul un readonly struct — ou un membre marqué readonly sur une struct qui ne l'est pas — tient la promesse.
L'égalité suit la même ligne de partage. Pour une classe qui ne surcharge rien, == est l'identité de référence. Pour une struct ordinaire, ==n'existe pas : la comparaison ne compile pas. Seul Equals est disponible, hérité de ValueType, qui compare les champs en passant par l'Equals de chacun — si bien que deux structs portant double.NaN sont égales, quand NaN == NaN est faux — et qui boxe son opérande, sa signature prenant un object.
Cette asymétrie n'est pas un oubli. Pour une classe, l'identité est toujours une réponse défendable ; pour une struct, le langage ne sait pas ce qu'« égal » veut dire, et refuse d'inventer un opérateur dont la sémantique lui échappe. string et DateTime surchargent == parce que leurs auteurs ont tranché ; un record en reçoit un du compilateur.
using System;
sealed class Money
{
public Money(decimal montant, string devise)
{
Montant = montant;
Devise = devise;
}
public decimal Montant { get; }
public string Devise { get; }
}
sealed record MoneyRecord(decimal Montant, string Devise);
readonly record struct MoneyStruct(decimal Montant, string Devise);
struct Point
{
public int X;
public int Y;
}
static class Egalite
{
public static void Executer()
{
var a = new Money(12m, "EUR");
var b = new Money(12m, "EUR");
Console.WriteLine(a == b); // False : deux objets distincts
Console.WriteLine(a.Equals(b)); // False : object.Equals compare l'identite
var r1 = new MoneyRecord(12m, "EUR");
var r2 = new MoneyRecord(12m, "EUR");
Console.WriteLine(r1 == r2); // True : == synthetise, champ par champ
var s1 = new MoneyStruct(12m, "EUR");
var s2 = new MoneyStruct(12m, "EUR");
Console.WriteLine(s1 == s2); // True : idem pour un record struct
var p1 = new Point { X = 1, Y = 2 };
var p2 = new Point { X = 1, Y = 2 };
// ValueType.Equals compare les champs, mais boxe son operande : sa
// signature prend un object.
Console.WriteLine(p1.Equals(p2)); // True
// Console.WriteLine(p1 == p2); // CS0019 : une struct ordinaire n'a
// // aucun operateur ==.
}
}Le nullable, et ce que le compilateur en sait
Le ? recouvre deux mécanismes que rien ne relie, sinon le caractère.
int? est Nullable<int> : une struct à deux champs, un bool et la valeur. Elle existe à l'exécution, n'est jamais nulle en mémoire, et age == null compile en !age.HasValue. Les opérateurs arithmétiques sont « levés » : un opérande absent donne un résultat absent. Les comparaisons, elles, rendent false dès qu'un opérande est absent — si bien que age > 0 et age <= 0 sont toutes deux fausses pour un age nul. Une condition et son contraire cessent de partitionner l'espace, et c'est de là que viennent les filtres qui perdent des lignes sans rien signaler.
Le boxing est la seule couture entre les deux mondes : boxer un Nullable<T> sans valeur produit une vraie référence nulle, et en boxer un renseigné produit un T boxé — jamais un Nullable<T> boxé, qui n'existe pas.
using System;
static class Levee
{
static int? Lire(bool present) => present ? 42 : (int?)null;
public static void Executer()
{
int? age = Lire(present: false);
Console.WriteLine(age.HasValue); // False
Console.WriteLine(age == null); // True : compile en !age.HasValue
// Operateurs leves : un operande absent donne un resultat absent.
int? somme = age + 1;
Console.WriteLine(somme.HasValue); // False
// Les comparaisons, elles, rendent false des deux cotes.
Console.WriteLine(age > 0); // False
Console.WriteLine(age <= 0); // False
// Le boxing est la seule couture entre les deux mondes : un Nullable
// sans valeur devient une vraie reference nulle.
object? vide = age;
Console.WriteLine(vide is null); // True
// Et un Nullable renseigne devient un int boxe, jamais un Nullable boxe.
object? pleine = Lire(present: true);
Console.WriteLine(pleine is int); // True
Console.WriteLine(pleine!.GetType().Name); // Int32
}
} Les types référence nullables sont tout autre chose : une annotation, et rien de plus. string et string? sont le même type à l'exécution ; l'annotation vit dans des métadonnées, et aucune instruction n'est émise pour la vérifier. Le compilateur tient pour chaque variable un état de flux — « peut être nulle » ou « ne l'est pas » — qu'il met à jour à chaque test et chaque affectation, et avertit quand l'état contredit l'usage.
! agit sur cet état, et sur rien d'autre : il ne produit aucune instruction. Il déclare que le développeur en sait plus que l'analyse — ce qui arrive, mais rarement. Quand il a tort, l'exception tombe au point de déréférencement, souvent plusieurs appels après le ! qui l'a autorisée. Ce code compile sans le moindre avertissement, et casse à la première commande sans acheteur :
#nullable enable
using System;
sealed record Client(string Nom, string? Email);
sealed record Commande(string Reference, Client? Acheteur);
static class Envoi
{
// Deux aveux sur une seule ligne. Aucun des deux ne produit la moindre
// instruction : ils effacent l'avertissement, pas le risque.
public static string Destinataire(Commande commande) => commande.Acheteur!.Email!;
public static void Executer()
{
var commande = new Commande("C-2025-001", Acheteur: null);
// NullReferenceException, levee au point de dereferencement et non
// a la ligne qui l'a autorise.
Console.WriteLine(Destinataire(commande));
}
}La même intention, écrite de façon à survivre au cas nul :
#nullable enable
using System;
sealed record Client(string Nom, string? Email);
sealed record Commande(string Reference, Client? Acheteur);
static class Envoi
{
// L'intention est « une adresse ou rien » : elle s'ecrit dans le type de
// retour, et le compilateur la propage a tous les appelants.
public static string? Destinataire(Commande commande) => commande.Acheteur?.Email;
// Un defaut tient sur une ligne, sans branche.
public static string DestinataireOuSupport(Commande commande) =>
commande.Acheteur?.Email ?? "[email protected]";
// Quand l'absence est une erreur metier, elle se signale au lieu de se taire.
public static string DestinataireExige(Commande commande)
{
if (commande.Acheteur is { Email: { } email })
{
return email;
}
throw new InvalidOperationException(
$"La commande {commande.Reference} n'a pas d'adresse de contact.");
}
public static void Executer()
{
var commande = new Commande("C-2025-001", Acheteur: null);
Console.WriteLine(Destinataire(commande) is null); // True
Console.WriteLine(DestinataireOuSupport(commande)); // [email protected]
}
}?. court-circuite le reste de la chaîne d'accès, pas l'expression qui l'entoure : client?.Adresse.Ville vaut null en entier quand client est nul, mais (client?.Adresse).Ville lève, les parenthèses ayant fermé la chaîne. Son résultat est toujours nullable, d'où texte?.Length en int? et non en int.
! reste défendable dans deux cas : une garde que l'analyse ne sait pas lire, faute des attributs NotNullWhen ou MemberNotNull qui la lui traduiraient, et un invariant établi par un cycle de vie que le compilateur ne voit pas. Partout ailleurs, il signale que le type ment.
Records
record déclare une classe, record struct une struct : le mot-clé ne change pas la famille du type, il demande au compilateur d'écrire l'égalité et quelques services autour.
Pour un record (classe), le compilateur synthétise Equals(T?), Equals(object?), GetHashCode, == et !=, un ToString qui imprime le nom du type et ses membres, un constructeur de copie — protégé, ou privé si le record est sealed — et une méthode de clonage. Un record struct ne reçoit ni l'un ni l'autre : with y recopie la valeur. La forme positionnelle ajoute un constructeur, un Deconstruct, et une propriété par paramètre : init sur un record ou un readonly record struct, set sur un record struct ordinaire.
L'égalité synthétisée compare les champs, pas les propriétés, avec EqualityComparer<T>.Default pour chacun : une propriété calculée n'entre donc pas dans l'égalité, et un champ dont le type ne redéfinit rien est comparé par référence.
Elle exige aussi le même type à l'exécution, par EqualityContract : une propriété qui rend le Type du record courant et que chaque descendant redéfinit. Elle est protégée et virtuelle partout où l'héritage est en jeu, et n'est privée que sur un record scellé sans base — un record struct, lui, n'en a aucune. Un dérivé n'est donc jamais égal à sa base, dans les deux sens. C'est ce qui rend l'égalité symétrique en présence d'héritage — la propriété qu'un Equals écrit à la main manque presque toujours.
using System;
// Chaque parametre positionnel devient une propriete publique init-only. Le
// compilateur ajoute Equals, GetHashCode, ==, !=, ToString, Deconstruct, un
// constructeur de copie (prive ici, car le record est scelle) et une methode
// de clonage.
sealed record Facture(string Numero, decimal MontantHT, decimal Taux)
{
// Une propriete calculee n'est pas un champ : elle n'entre pas dans l'egalite.
public decimal MontantTTC => MontantHT * (1 + Taux);
}
static class Facturation
{
public static void Executer()
{
var initiale = new Facture("F-2025-001", 100m, 0.20m);
// with appelle le constructeur de copie, qui recopie tous les champs,
// puis affecte les seules proprietes citees.
var corrigee = initiale with { MontantHT = 120m };
Console.WriteLine(initiale.MontantHT); // 100 : l'originale n'a pas bouge
Console.WriteLine(corrigee.MontantHT); // 120
Console.WriteLine(corrigee.Numero); // F-2025-001 : recopie sans rien dire
Console.WriteLine(initiale == corrigee); // False
Console.WriteLine(initiale == (corrigee with { MontantHT = 100m })); // True
// ToString imprime le type et ses membres : utile en journalisation,
// et une raison de ne pas mettre de secret dans un record.
Console.WriteLine(initiale);
}
} La copie produite par with est superficielle, et l'égalité par champs ne connaît pas le contenu d'un tableau. Les deux se combinent en un piège classique :
using System;
sealed record Lot(string Code, string[] Articles);
static class Stock
{
public static void Executer()
{
var a = new Lot("L1", ["stylo", "cahier"]);
var b = new Lot("L1", ["stylo", "cahier"]);
// L'egalite synthetisee compare chaque champ avec
// EqualityComparer<T>.Default. Pour un tableau, c'est l'identite de
// reference : meme contenu, references differentes, donc inegaux.
Console.WriteLine(a == b); // False
// Et l'immuabilite s'arrete au premier niveau : la propriete est
// init-only, le contenu du tableau ne l'est pas.
var copie = a with { Code = "L2" };
copie.Articles[0] = "gomme";
Console.WriteLine(a.Articles[0]); // gomme
}
} Écrire soi-même Equals(T?) suffit à reprendre la main : le compilateur cesse de le synthétiser, et == comme Equals(object?) passent par celui-là — au prix du contrôle d'EqualityContract, que seul le sealed de l'exemple rend sans conséquence. Il faut alors écrire GetHashCode avec lui : deux instances égales doivent rendre la même empreinte, sans quoi un dictionnaire les range dans deux seaux.
using System;
using System.Linq;
sealed record Lot(string Code, string[] Articles)
{
// Declarer Equals(Lot?) empeche le compilateur de le synthetiser : == et
// Equals(object?) passent desormais par celui-ci.
public bool Equals(Lot? autre) =>
autre is not null && Code == autre.Code && Articles.SequenceEqual(autre.Articles);
// Obligatoire des lors qu'on ecrit Equals : sans cela, deux instances
// egales tomberaient dans deux seaux differents d'un dictionnaire.
public override int GetHashCode()
{
var empreinte = new HashCode();
empreinte.Add(Code);
foreach (var article in Articles)
{
empreinte.Add(article);
}
return empreinte.ToHashCode();
}
}
static class Stock
{
public static void Executer()
{
var a = new Lot("L1", ["stylo", "cahier"]);
var b = new Lot("L1", ["stylo", "cahier"]);
Console.WriteLine(a == b); // True
}
}Un record remplace une classe quand le type est une valeur sans identité propre : DTO, message, clé de cache, bloc de configuration. Il cesse de convenir dès qu'il y a une identité : une entité persistée reste la même après qu'on lui a changé un champ, et deux lignes de même contenu sous deux clés distinctes sont deux choses — l'égalité par champs dit le contraire dans les deux cas.
Tuples et déconstruction
(int, string) est ValueTuple<int, string> : une struct à champs publics Item1 et Item2. Struct, donc recopiée à chaque affectation ; champs publics, donc modifiable. Un tuple nommé en retour évite de déclarer un type pour un besoin local.
using System;
using System.Collections.Generic;
static class Statistiques
{
// Un tuple nomme rend trois valeurs sans creer de type. Les noms sont
// portes par la signature : l'appelant les voit.
public static (int Minimum, int Maximum, double Moyenne) Resumer(IReadOnlyList<int> valeurs)
{
if (valeurs.Count == 0)
{
throw new ArgumentException("Serie vide.", nameof(valeurs));
}
var minimum = int.MaxValue;
var maximum = int.MinValue;
var somme = 0L;
foreach (var valeur in valeurs)
{
minimum = Math.Min(minimum, valeur);
maximum = Math.Max(maximum, valeur);
somme += valeur;
}
return (minimum, maximum, (double)somme / valeurs.Count);
}
public static void Executer()
{
var resume = Resumer([3, 9, 4]);
Console.WriteLine(resume.Maximum); // 9
// Deconstruction : chaque element dans sa variable, _ pour ce qu'on ne
// lit pas. Le discard ne cree aucune variable.
var (minimum, _, moyenne) = Resumer([3, 9, 4]);
Console.WriteLine($"{minimum} / {moyenne:0.00}");
}
} La déconstruction n'est pas réservée aux tuples : tout type exposant une méthode Deconstruct à paramètres out se déconstruit, y compris par méthode d'extension — les records positionnels en reçoivent une d'office. _ est un discard : il ne crée aucune variable, et peut se répéter.
Les noms d'éléments, eux, n'existent qu'à la compilation. Un attribut posé sur les signatures les conserve, pour que l'appelant les voie, mais le type reste ValueTuple<...>. Trois conséquences : (int Largeur, int Hauteur) et (int X, int Y) sont le même type et s'affectent l'un à l'autre ; deux surcharges ne peuvent pas différer par les seuls noms d'éléments ; et la réflexion, donc la sérialisation, ne voit que Item1 et Item2.
using System;
static class Noms
{
public static void Executer()
{
(int Largeur, int Hauteur) taille = (800, 600);
// Les noms n'existent qu'a la compilation : le type reel est
// ValueTuple<int, int>, avec des champs publics Item1 et Item2.
Console.WriteLine(taille.Item1 == taille.Largeur); // True
// Deux tuples de memes types sont le meme type : l'affectation passe,
// et ce sont les noms de la cible qui valent ensuite.
(int X, int Y) point = taille;
Console.WriteLine(point.X); // 800
// == compare element par element et ignore les noms.
Console.WriteLine(point == (800, 600)); // True
// Struct a champs publics : modifiable, et recopiee a l'affectation.
var copie = point;
copie.X = 1;
Console.WriteLine(point.X); // 800
}
}Ce point trace la frontière avec un record.
| Tuple | Record | |
|---|---|---|
| Nom du type | aucun, effacé à la compilation | déclaré, visible en réflexion |
| Égalité | élément par élément | champ par champ, plus le type exact |
| Validation | impossible | dans le constructeur |
| Membres | aucun qu'on puisse déclarer | propriétés calculées, méthodes |
| Mutabilité | champs publics modifiables | init, sauf record struct non readonly |
La règle : un tuple pour un retour local, quand nommer un type coûterait plus qu'il ne rapporte ; un record dès que la valeur traverse une frontière publique, est sérialisée, ou doit pouvoir refuser un état.
Pattern matching
Le pattern matching remplace une cascade de if par une description de forme, et donne au compilateur de quoi vérifier qu'aucun cas n'est oublié.
is teste et déclare en une fois. is not null ne passe jamais par un opérateur défini par l'utilisateur, alors que != null appelle l'operator != du type s'il en existe un : sur un type qui surcharge l'égalité, les deux écritures posent des questions différentes — « cette référence n'est-elle pas nulle » contre « ce type considère-t-il que cette valeur diffère de null ».
Une switch expression a un type unique, ses bras sont essayés dans l'ordre, et le premier motif qui correspond gagne. Quand elle n'est pas exhaustive, le compilateur émet un avertissement et non une erreur, et l'expression lève une SwitchExpressionException si rien ne correspond. Un projet qui ne traite pas ses avertissements comme des erreurs perd donc la garantie.
using System;
abstract record Paiement
{
public required decimal Montant { get; init; }
}
sealed record Carte : Paiement
{
public required string Reseau { get; init; }
public bool SansContact { get; init; }
}
sealed record Virement : Paiement
{
public required string Iban { get; init; }
}
sealed record Especes : Paiement;
static class Frais
{
public static decimal Calculer(Paiement paiement) =>
paiement switch
{
// Motif de type + motif de propriete + garde. Une garde ne compte
// pas dans l'exhaustivite : il faut toujours un bras non garde
// derriere, pour le meme type.
Carte { SansContact: true } when paiement.Montant <= 50m => 0m,
// Motif de propriete multiple et motif relationnel.
Carte { Reseau: "CB", Montant: > 1000m } => 4m,
Carte => 1m,
// Motif de type avec declaration : v n'existe que dans ce bras.
Virement v when v.Iban.StartsWith("FR") => 0m,
Virement => 2m,
Especes => 0m,
// Sans ce bras, le compilateur avertit (CS8509) et l'expression
// leve SwitchExpressionException si rien ne correspond.
_ => throw new ArgumentOutOfRangeException(nameof(paiement)),
};
public static void Executer()
{
var petite = new Carte { Montant = 20m, Reseau = "CB", SansContact = true };
var grosse = new Carte { Montant = 2000m, Reseau = "CB" };
var virement = new Virement { Montant = 500m, Iban = "DE89370400440532013000" };
Console.WriteLine(Calculer(petite)); // 0 : sans contact, sous 50 euros
Console.WriteLine(Calculer(grosse)); // 4 : CB au-dela de 1000 euros
Console.WriteLine(Calculer(virement)); // 2 : virement hors France
}
} Les gardes when ne comptent pas dans l'analyse d'exhaustivité, et le compilateur ne peut pas évaluer leur condition : un bras gardé ne couvre donc jamais un type, et il en faut toujours un autre, non gardé, pour le même motif. Il ne signale pas davantage qu'un bras est rendu inaccessible à travers une garde, pour la même raison.
Les motifs se composent : type, propriété imbriquée, relationnel (> 1000m), logique (and, or, not), var pour capturer sans filtrer. Les motifs de liste, arrivés avec C# 11, s'appliquent à tout type comptable et indexable, avec au plus une tranche .. par motif.
using System;
static class LigneDeCommande
{
// Les motifs de liste s'appliquent a tout type comptable et indexable :
// tableau, List<T>, string, Span<T>.
public static string Decrire(string[] arguments) =>
arguments switch
{
[] => "aucun argument",
["--help"] or ["-h"] => "aide",
["build", var cible] => $"construire {cible}",
// .. est le motif de tranche : au plus un par motif, et il peut
// capturer le reste.
["build", var cible, .. var options] =>
$"construire {cible} avec {options.Length} option(s)",
// Le premier bras couvre la longueur 0 et celui-ci toutes les
// autres : le compilateur n'avertit pas sur l'exhaustivite.
[var premier, ..] => $"commande inconnue : {premier}",
};
public static void Executer()
{
Console.WriteLine(Decrire([])); // aucun argument
Console.WriteLine(Decrire(["build", "web"])); // construire web
Console.WriteLine(Decrire(["build", "web", "--watch"])); // ... avec 1 option(s)
Console.WriteLine(Decrire(["publish"])); // commande inconnue
}
} Un motif de propriété exécute l'accesseur. Sur une propriété qui calcule ou déclenche un chargement, un switch d'apparence déclarative fait travailler la machine autant qu'une suite de if.
Chaînes et interpolation
string est scellée, immuable, encodée en UTF-16. Toute opération qui « modifie » une chaîne en crée une autre — d'où des chaînes partageables sans copie et sûres entre threads, et une concaténation en boucle coûteuse.
L'interpolation $"..." n'est plus un string.Format déguisé. Depuis C# 10, le compilateur l'abaisse vers un DefaultInterpolatedStringHandler : un tampon loué, et un appel générique AppendFormatted<T> par trou, ce qui évite le boxing des types valeur et le tableau object[] intermédiaire. Quand tous les trous sont des string sans alignement ni format, il réduit encore, à un simple string.Concat.
Le mécanisme est ouvert : une API peut fournir son handler et ne pas évaluer les trous — Debug.Assert s'en sert pour ne formater son message que si la condition est fausse.
Les chaînes brutes, arrivées avec C# 11, suppriment l'échappement : le délimiteur compte au moins trois guillemets, davantage si le contenu en contient autant d'affilée. En multiligne, rien ne peut suivre le délimiteur ouvrant, et l'indentation de la ligne de fermeture est retirée de toutes les autres — le compilateur refuse une ligne moins indentée qu'elle : l'indentation devient une règle vérifiée, pas une convention.
using System;
static class Templates
{
// Aucun echappement : le contenu est litteral. Le delimiteur compte au
// moins trois guillemets, davantage si le contenu en contient autant
// d'affilee. Les guillemets du JSON passent tels quels, et la barre
// oblique inverse reste une barre oblique inverse.
public const string Commande = """
{
"reference": "C-2025-001",
"libelle": "Cle \"USB\" 64 Go",
"lignes": [{ "sku": "USB-64", "quantite": 2 }]
}
""";
public static void Executer()
{
// L'indentation de la ligne de fermeture est retiree de toutes les
// autres : l'accolade ouvrante est bien en colonne 0 dans la chaine.
Console.WriteLine(Commande.StartsWith('{')); // True
// Forme interpolee : deux $ exigent deux accolades pour ouvrir un trou,
// donc une accolade seule reste litterale.
var reference = "C-2025-002";
var corps = $$"""
{
"reference": "{{reference}}",
"lignes": []
}
""";
Console.WriteLine(corps);
}
} Le nombre de $ fixe le nombre d'accolades nécessaires pour ouvrir un trou, et toute accolade en nombre inférieur reste littérale : de quoi écrire du JSON, du CSS ou du code généré sans doubler chaque accolade.
StringBuilder devient utile quand le nombre de morceaux n'est pas connu à la compilation : une boucle, un assemblage conditionnel. Le cas de la boucle mérite d'être écrit : chaque += y alloue une chaîne neuve et recopie tout ce qui précède, si bien que le volume copié croît comme le carré de la taille finale.
using System;
static class Rapport
{
public static string Assembler(string[] lignes)
{
var sortie = "";
foreach (var ligne in lignes)
{
// Chaque tour alloue une chaine neuve et recopie tout ce qui
// precede. Pour n lignes : n allocations, et un volume copie qui
// croit comme le carre de la taille finale.
sortie += ligne + Environment.NewLine;
}
return sortie;
}
}La même méthode, avec un tampon qui grandit au lieu d'une suite de chaînes jetées :
using System;
using System.Text;
static class Rapport
{
public static string Assembler(string[] lignes)
{
var sortie = new StringBuilder();
foreach (var ligne in lignes)
{
sortie.AppendLine(ligne);
}
return sortie.ToString();
}
// Quand la source est deja une sequence, le cadre est fourni : une seule
// mesure, une seule allocation.
public static string AssemblerCourt(string[] lignes) =>
string.Join(Environment.NewLine, lignes);
// A l'inverse, une concatenation en nombre fixe n'a aucun besoin de
// StringBuilder : le compilateur la reduit a un unique string.Concat.
public static string Entete(string nom, string date) => "Rapport " + nom + " du " + date;
}StringBuilder n'est pas un tableau qu'on redimensionne : c'est une liste chaînée de morceaux, si bien que sa croissance ne recopie pas ce qui est déjà écrit. Le passage se justifie par la boucle, jamais par le nombre de + : une concaténation en nombre fixe, dans une seule expression, est réduite par le compilateur à un unique string.Concat, qui mesure puis alloue une fois. Trois += successifs, eux, restent trois allocations.