Skip to content

5. Classes .NET d'usage courant

Nous présentons ici quelques classes de la plate-forme .NET fréquemment utilisées : chaînes de caractères, tableaux, collections, fichiers, expressions régulières. Nous y ajoutons LINQ (Language Integrated Query), que le cours de 2008 laissait de côté alors qu'il était déjà disponible depuis C# 3 : c'est aujourd'hui la façon la plus courante de manipuler des collections en C#. Auparavant, nous montrons comment obtenir des renseignements sur les milliers de classes disponibles. Cette aide est indispensable au développeur C#, même confirmé.

Les exemples de ce chapitre sont, sauf l'application exemple de la fin, des applications fichier unique exécutées par [dotnet run fichier.cs]. Ils se trouvent dans le dossier [05-classes-dotnet] du code du cours et s'exécutent en culture française (fr-FR).

5.1. Chercher de l'aide sur les classes .NET

Le cours de 2008 décrivait longuement, copies d'écran à l'appui, l'aide MSDN installée avec Visual Studio 2008. Cette aide locale n'existe plus : la documentation de .NET est aujourd'hui en ligne, gratuite, et mise à jour à chaque version.

5.1.1. La documentation en ligne

Le point d'entrée est le site learn.microsoft.com de Microsoft. On y trouve :

  • la documentation du langage C# : https://learn.microsoft.com/fr-fr/dotnet/csharp/. Elle contient une présentation du langage, des tutoriels, la référence du langage (mots clés, opérateurs, instructions) et une rubrique « Nouveautés » qui décrit chaque version de C# ;
  • la référence de l'API .NET : https://learn.microsoft.com/fr-fr/dotnet/api/. Elle décrit, espace de noms par espace de noms, toutes les classes de .NET : leurs constructeurs, propriétés et méthodes, avec des exemples ;
  • le navigateur d'API, sur la même page : une zone de recherche dans laquelle on tape un nom de classe ou de méthode ([StringBuilder], [Regex.Match]…). Une liste déroulante permet de choisir la version de .NET (ici .NET 10).

La page d'une classe, par exemple System.String, donne :

  • l'espace de noms de la classe et l'assembly qui la contient ;
  • sa déclaration, sa classe parente et les interfaces qu'elle implémente ;
  • la liste de ses constructeurs, propriétés et méthodes, chacune avec un lien vers une page détaillée qui donne ses différentes surcharges, ses paramètres, la valeur rendue, les exceptions possibles et souvent un exemple ;
  • des remarques sur son utilisation.

Les pages sont disponibles en français (traduction automatique) et en anglais (texte original, parfois plus précis). On passe de l'une à l'autre en remplaçant [fr-fr] par [en-us] dans l'adresse. Une recherche sur un moteur de recherche avec le nom complet de la classe (« System.Text.StringBuilder ») mène en général directement à la bonne page.

5.1.2. L'aide de l'éditeur

L'éditeur de code (Visual Studio, Visual Studio Code avec l'extension C# Dev Kit, Rider) fournit une aide immédiate, sans quitter le code :

  • l'autocomplétion (IntelliSense) : après avoir tapé [uneChaine.], l'éditeur propose la liste des propriétés et méthodes de l'objet, avec un résumé de chacune ;
  • le survol : placer la souris sur un nom de classe, de méthode ou de variable affiche sa déclaration et le résumé de sa documentation ;
  • l'aide sur les paramètres : en tapant la parenthèse ouvrante d'un appel, l'éditeur affiche les différentes surcharges de la méthode et le rôle de chaque paramètre ;
  • atteindre la définition (touche F12) : affiche la déclaration de la classe ou de la méthode. Pour une classe de .NET, l'éditeur affiche les déclarations publiques reconstituées à partir de l'assembly, avec leurs commentaires de documentation ;
  • dans Visual Studio, la touche F1 sur un nom de classe ou de méthode ouvre la page correspondante de la documentation en ligne.

Ces commentaires de documentation, que l'on voit au survol, sont ceux qui servent à générer la référence en ligne. On peut en écrire de semblables dans son propre code, avec la syntaxe [/// <summary> ... </summary>] placée au-dessus d'une classe ou d'une méthode : l'éditeur les affichera de la même façon.

5.2. Les chaînes de caractères

5.2.1. La classe System.String

Le type [string] de C# est un alias de la classe [System.String]. Elle présente de nombreuses propriétés et méthodes. En voici quelques-unes :

membre
rôle
[public int Length { get; }]
nombre de caractères de la chaîne
[public char this[int index] { get; }]
le caractère n° [index] : [chaine[i]]
[public bool StartsWith(string value)]
rend [true] si la chaîne commence par [value]
[public bool EndsWith(string value)]
rend [true] si la chaîne se termine par [value]
[public bool Contains(string value)]
rend [true] si la chaîne contient [value]
[public bool Equals(string? value)]
rend [true] si la chaîne est égale à [value] ; équivalent à [chaine == value]
[public int IndexOf(string value)]
première position de [value] dans la chaîne, ou -1. Une autre surcharge commence la recherche à la position [startIndex]
[public int IndexOf(char value)]
idem pour le caractère [value]
[public int LastIndexOf(char value)]
idem [IndexOf] mais rend la dernière position au lieu de la première
[public string Insert(int startIndex, string value)]
insère la chaîne [value] dans la chaîne en position [startIndex]
[public static string Join(string separator, params string[] value)]
méthode de classe : concatène les éléments de [value] en les séparant par [separator]
[public string Replace(char oldChar, char newChar)]
rend une copie de la chaîne où le caractère [oldChar] a été remplacé par [newChar] ; une surcharge remplace une chaîne par une autre
[public string[] Split(params char[] separator)]
la chaîne est vue comme une suite de champs séparés par les caractères de [separator] ; le résultat est le tableau de ces champs
[public string Substring(int startIndex, int length)]
sous-chaîne de [length] caractères commençant à la position [startIndex]
[public string ToLower()], [public string ToUpper()]
rend la chaîne en minuscules, en majuscules
[public string Trim()]
rend la chaîne débarrassée de ses espaces de début et de fin ([TrimStart], [TrimEnd] : d'un seul côté)

On notera un point important : une chaîne de type [string] est immuable, elle ne peut pas être modifiée après sa création. Lorsqu'une méthode rend une chaîne de caractères, celle-ci est une chaîne différente de la chaîne sur laquelle a été appliquée la méthode. Ainsi [s1.Trim()] rend une chaîne [s2], et [s1] et [s2] sont deux chaînes différentes : [s1] n'a pas changé.

Une chaîne [c] peut être considérée comme un tableau de caractères en lecture seule :

  • [c[i]] est le caractère n° i de [c] (le premier a le n° 0) ;
  • [c.Length] est le nombre de caractères de [c].

Le programme [chaines-01.cs] est le suivant :

// quelques méthodes de la classe String
string uneChaine = "l'oiseau vole au-dessus des nuages";
Affiche("uneChaine=" + uneChaine);
Affiche("uneChaine.Length=" + uneChaine.Length);
Affiche("uneChaine[10]=" + uneChaine[10]);
Affiche("uneChaine.IndexOf(\"vole\")=" + uneChaine.IndexOf("vole"));
Affiche("uneChaine.IndexOf(\"x\")=" + uneChaine.IndexOf("x"));
Affiche("uneChaine.LastIndexOf('a')=" + uneChaine.LastIndexOf('a'));
Affiche("uneChaine.LastIndexOf('x')=" + uneChaine.LastIndexOf('x'));
Affiche("uneChaine.Substring(4, 7)=" + uneChaine.Substring(4, 7));
Affiche("uneChaine.ToUpper()=" + uneChaine.ToUpper());
Affiche("uneChaine.ToLower()=" + uneChaine.ToLower());
Affiche("uneChaine.Replace('a', 'A')=" + uneChaine.Replace('a', 'A'));
Affiche("uneChaine.Insert(9, \"ne \")=" + uneChaine.Insert(9, "ne "));
// découpage de la chaîne en mots (séparateur : les espaces)
string[] champs = uneChaine.Split();
for (int i = 0; i < champs.Length; i++)
{
    Affiche("champs[" + i + "]=[" + champs[i] + "]");
}
Affiche("string.Join(\":\", champs)=" + string.Join(":", champs));
Affiche("\"  abc  \".Trim()=[" + "  abc  ".Trim() + "]");
// la chaîne d'origine n'a pas été modifiée
Affiche("uneChaine=" + uneChaine);

// une méthode locale d'affichage
static void Affiche(string msg)
{
    Console.WriteLine(msg);
}

Résultats :

> dotnet run chaines-01.cs
uneChaine=l'oiseau vole au-dessus des nuages
uneChaine.Length=34
uneChaine[10]=o
uneChaine.IndexOf("vole")=9
uneChaine.IndexOf("x")=-1
uneChaine.LastIndexOf('a')=30
uneChaine.LastIndexOf('x')=-1
uneChaine.Substring(4, 7)=seau vo
uneChaine.ToUpper()=L'OISEAU VOLE AU-DESSUS DES NUAGES
uneChaine.ToLower()=l'oiseau vole au-dessus des nuages
uneChaine.Replace('a', 'A')=l'oiseAu vole Au-dessus des nuAges
uneChaine.Insert(9, "ne ")=l'oiseau ne vole au-dessus des nuages
champs[0]=[l'oiseau]
champs[1]=[vole]
champs[2]=[au-dessus]
champs[3]=[des]
champs[4]=[nuages]
string.Join(":", champs)=l'oiseau:vole:au-dessus:des:nuages
"  abc  ".Trim()=[abc]
uneChaine=l'oiseau vole au-dessus des nuages

Commentaires :

  • ligne 5 : le caractère n° 10, donc le 11e caractère de la chaîne ;
  • lignes 7, 9 : une recherche infructueuse rend -1 ;
  • ligne 10 : les 7 caractères commençant à la position 4 ;
  • ligne 14 : une insertion en position 9, avant le mot « vole » ;
  • ligne 16 : [Split] sans paramètre découpe la chaîne sur les espaces (espace, tabulation, saut de ligne…). Le cours de 2008 écrivait [Split(null)], ce qui revient au même ;
  • ligne 21 : la méthode statique [Join] fait l'inverse de [Split] ;
  • ligne 23 : toutes les méthodes précédentes ont rendu de nouvelles chaînes : [uneChaine] n'a pas été modifiée (ligne 21 des résultats) ;
  • lignes 27-29 : une fonction locale, comme au chapitre 3.

5.2.2. Les méthodes Split et Join

Le programme [chaines-02.cs] approfondit le découpage d'une chaîne en champs :

// Split et Join
string ligne = "un:deux::trois:";
// découpage avec le séparateur ':'
string[] champs = ligne.Split(':');
for (int i = 0; i < champs.Length; i++)
{
    Console.WriteLine($"champs[{i}]=[{champs[i]}]");
}
// l'opération inverse
Console.WriteLine($"join=[{string.Join(":", champs)}]");

// les options de Split : suppression des champs vides
champs = ligne.Split(':', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine($"RemoveEmptyEntries : {champs.Length} champs [{string.Join("|", champs)}]");

// plusieurs séparateurs et suppression des espaces autour des champs
string personne = " dupont , jean ;  45 ";
champs = personne.Split([',', ';'], StringSplitOptions.TrimEntries);
Console.WriteLine($"TrimEntries : {champs.Length} champs [{string.Join("|", champs)}]");

// les deux options combinées
string liste = "  rouge ,, vert ,  , bleu ";
champs = liste.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Console.WriteLine($"les deux options : {champs.Length} champs [{string.Join("|", champs)}]");

// un séparateur qui est une chaîne
string texte = "pomme -> poire -> abricot";
champs = texte.Split(" -> ");
Console.WriteLine($"séparateur chaîne : [{string.Join("|", champs)}]");

// Join accepte toute collection, pas seulement des chaînes
int[] nombres = [1, 2, 3, 4];
Console.WriteLine($"Join sur des entiers : {string.Join(" + ", nombres)}");

Résultats :

> dotnet run chaines-02.cs
champs[0]=[un]
champs[1]=[deux]
champs[2]=[]
champs[3]=[trois]
champs[4]=[]
join=[un:deux::trois:]
RemoveEmptyEntries : 3 champs [un|deux|trois]
TrimEntries : 3 champs [dupont|jean|45]
les deux options : 3 champs [rouge|vert|bleu]
séparateur chaîne : [pomme|poire|abricot]
Join sur des entiers : 1 + 2 + 3 + 4

Commentaires :

  • ligne 4 : les champs sont séparés par le caractère [:]. Deux [:] consécutifs, ou un [:] en fin de chaîne, délimitent un champ vide : on obtient 5 champs dont 2 vides (lignes 4, 6 des résultats). [Join] reconstitue la chaîne de départ ;
  • la méthode [Split] a de nombreuses surcharges. Celle de 2008, [Split(char[] separator)], s'écrit aujourd'hui [Split(params char[] separator)] : le mot clé [params] permet de passer les séparateurs un par un, [Split(':')] ou [Split(',', ';')], sans construire de tableau ;
  • ligne 13 : l'énumération [StringSplitOptions] modifie le découpage. [RemoveEmptyEntries] supprime les champs vides ;
  • ligne 18 : [TrimEntries] (.NET 5) supprime les espaces autour de chaque champ. Les séparateurs sont ici passés sous la forme d'une expression de collection [',', ';'] ;
  • ligne 23 : les deux options se combinent avec l'opérateur [|]. Les espaces sont supprimés avant le test des champs vides : le champ formé de deux espaces entre deux virgules disparaît ;
  • ligne 28 : le séparateur peut être une chaîne de plusieurs caractères ;
  • ligne 33 : [Join] accepte une collection d'éléments de type quelconque ; chaque élément est converti en chaîne par sa méthode [ToString].

5.2.3. Rechercher et comparer des chaînes

La comparaison de chaînes est plus subtile qu'il n'y paraît : faut-il distinguer les majuscules des minuscules ? Comment classer « élève » par rapport à « essai » ? Le programme [chaines-03.cs] présente les méthodes de recherche et de comparaison :

// rechercher et comparer des chaînes
string titre = "Apprentissage du langage C# avec .NET";

// Contains, StartsWith, EndsWith : comparaison exacte par défaut
Console.WriteLine($"Contains(\"langage\") : {titre.Contains("langage")}");
Console.WriteLine($"Contains(\"LANGAGE\") : {titre.Contains("LANGAGE")}");
Console.WriteLine($"StartsWith(\"Apprentissage\") : {titre.StartsWith("Apprentissage")}");
Console.WriteLine($"EndsWith('T') : {titre.EndsWith('T')}");

// on précise le mode de comparaison avec l'énumération StringComparison
Console.WriteLine($"Contains(\"LANGAGE\", OrdinalIgnoreCase) : {titre.Contains("LANGAGE", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"StartsWith(\"apprentissage\", OrdinalIgnoreCase) : {titre.StartsWith("apprentissage", StringComparison.OrdinalIgnoreCase)}");

// égalité : l'opérateur == compare les contenus des chaînes
string reponse = "OUI";
Console.WriteLine($"reponse == \"oui\" : {reponse == "oui"}");
Console.WriteLine($"string.Equals(reponse, \"oui\", OrdinalIgnoreCase) : {string.Equals(reponse, "oui", StringComparison.OrdinalIgnoreCase)}");

// ordre alphabétique : comparaison selon la culture ou selon les codes des caractères
string a = "élève", b = "essai";
Console.WriteLine($"Compare(\"{a}\", \"{b}\", CurrentCulture) : {string.Compare(a, b, StringComparison.CurrentCulture)}");
Console.WriteLine($"Compare(\"{a}\", \"{b}\", Ordinal) : {string.Compare(a, b, StringComparison.Ordinal)}");

// chaînes vides ou blanches
string? vide = null;
string blanche = "   ";
Console.WriteLine($"IsNullOrEmpty(null) : {string.IsNullOrEmpty(vide)}");
Console.WriteLine($"IsNullOrEmpty(\"   \") : {string.IsNullOrEmpty(blanche)}");
Console.WriteLine($"IsNullOrWhiteSpace(\"   \") : {string.IsNullOrWhiteSpace(blanche)}");

// cadrage
Console.WriteLine($"[{"abc".PadLeft(8)}] [{"abc".PadRight(8, '.')}] [{new string('-', 8)}]");

Résultats :

> dotnet run chaines-03.cs
Contains("langage") : True
Contains("LANGAGE") : False
StartsWith("Apprentissage") : True
EndsWith('T') : True
Contains("LANGAGE", OrdinalIgnoreCase) : True
StartsWith("apprentissage", OrdinalIgnoreCase) : True
reponse == "oui" : False
string.Equals(reponse, "oui", OrdinalIgnoreCase) : True
Compare("élève", "essai", CurrentCulture) : -1
Compare("élève", "essai", Ordinal) : 132
IsNullOrEmpty(null) : True
IsNullOrEmpty("   ") : False
IsNullOrWhiteSpace("   ") : True
[     abc] [abc.....] [--------]

Commentaires :

  • lignes 5-8 : [Contains], [StartsWith] et [EndsWith] font par défaut une comparaison exacte, caractère par caractère : « LANGAGE » n'est pas trouvé ;
  • lignes 11-12 : un paramètre de type [StringComparison] précise le mode de comparaison. Les valeurs les plus utiles sont :
    • [Ordinal] : comparaison des codes des caractères ;
    • [OrdinalIgnoreCase] : idem, sans tenir compte de la casse ;
    • [CurrentCulture], [CurrentCultureIgnoreCase] : comparaison selon les règles de la langue de la machine ;
    • [InvariantCulture] : comparaison selon des règles indépendantes de la machine ;
  • ligne 16 : l'opérateur [] compare le contenu des chaînes, comme la méthode [Equals]. C'est une différence importante avec Java, où [] compare les références ;
  • ligne 17 : la forme statique de [Equals] accepte elle aussi un [StringComparison]. C'est la bonne façon de tester une réponse « oui » saisie au clavier en majuscules ou en minuscules ;
  • lignes 21-22 : [string.Compare] rend un entier négatif, nul ou positif selon que la première chaîne précède, est égale ou suit la seconde. En culture française, « élève » précède « essai » (le é est classé avec le e, ligne 10 des résultats). En comparaison ordinale, « élève » suit « essai » car le code du premier caractère é (233) est supérieur à celui du caractère e (101) : la comparaison ordinale rend ici la différence des deux codes, 132. On utilisera l'ordre de la culture pour trier des mots destinés à un utilisateur et l'ordre ordinal pour comparer des identifiants techniques (noms de fichiers, clés…) ;
  • lignes 27-29 : deux méthodes statiques pour tester une chaîne absente ([null]), vide, ou ne contenant que des espaces ;
  • ligne 32 : [PadLeft] et [PadRight] complètent une chaîne jusqu'à une longueur donnée ; le constructeur [new string('-', 8)] répète un caractère.

5.2.4. Interpolation et formats

L'interpolation de chaînes [$"..."] (C# 6) a été utilisée depuis le début du cours. Elle accepte, comme la méthode [string.Format], une largeur et un format pour chaque expression : [{expression,largeur:format}]. Le programme [chaines-04.cs] en donne des exemples, ainsi que les chaînes brutes de C# 11 :

// interpolation, formats et chaînes brutes
string nom = "dupont";
int age = 45;
decimal salaire = 2345.678m;
double taux = 0.0525;
DateTime date = new(2026, 10, 3, 14, 5, 9);

// interpolation simple
Console.WriteLine($"{nom} a {age} ans");
// {expression,largeur:format} : largeur > 0 cadre à droite, < 0 cadre à gauche
Console.WriteLine($"[{nom,10}] [{nom,-10}] [{age,5}]");
Console.WriteLine($"[{salaire,12:F2}] [{salaire,-12:F1}] [{salaire:N2}] [{salaire:C}]");
Console.WriteLine($"[{taux:P2}] [{age:D5}] [{age:X}] [{1234567:N0}]");
Console.WriteLine($"[{date:dd/MM/yyyy}] [{date:HH:mm:ss}] [{date:dddd d MMMM yyyy}]");
// une expression quelconque, ici une expression conditionnelle entre parenthèses
Console.WriteLine($"{nom} est {(age >= 18 ? "majeur" : "mineur")}");

// la méthode string.Format et la notation {n} des versions précédentes de C#
Console.WriteLine(string.Format("{0} a {1} ans et gagne {2:F2} euros", nom, age, salaire));

// un tableau aligné
string[] produits = ["pomme", "poire", "abricot"];
decimal[] prix = [2.5m, 3.15m, 12m];
for (int i = 0; i < produits.Length; i++)
{
    Console.WriteLine($"{produits[i],-10}|{prix[i],8:F2} €");
}

// chaîne verbatim : \ n'est pas un caractère d'échappement
string chemin = @"C:\data\cours\infos.txt";
Console.WriteLine(chemin);

// chaîne brute (C# 11) : délimitée par au moins trois guillemets, sur plusieurs lignes
string json = """
    {
      "nom": "dupont",
      "chemin": "C:\data"
    }
    """;
Console.WriteLine(json);
// chaîne brute interpolée : avec $$, les accolades simples restent littérales
string json2 = $$"""{ "nom": "{{nom}}", "age": {{age}} }""";
Console.WriteLine(json2);

Résultats :

> dotnet run chaines-04.cs
dupont a 45 ans
[    dupont] [dupont    ] [   45]
[     2345,68] [2345,7      ] [2 345,68] [2 345,68 €]
[5,25 %] [00045] [2D] [1 234 567]
[03/10/2026] [14:05:09] [samedi 3 octobre 2026]
dupont est majeur
dupont a 45 ans et gagne 2345,68 euros
pomme     |    2,50 €
poire     |    3,15 €
abricot   |   12,00 €
C:\data\cours\infos.txt
{
  "nom": "dupont",
  "chemin": "C:\data"
}
{ "nom": "dupont", "age": 45 }

Commentaires :

  • ligne 11 : une largeur positive cadre la valeur à droite dans un champ de 10 caractères, une largeur négative la cadre à gauche ;
  • lignes 12-14 : les formats les plus courants :
format
signification
exemple (fr-FR)
[F2]
nombre en virgule fixe, 2 décimales
2345,68
[N2]
idem avec séparateur des milliers
2 345,68
[C]
montant monétaire de la culture
2 345,68 €
[P2]
pourcentage (la valeur est multipliée par 100)
5,25 %
[D5]
entier sur 5 chiffres au moins, complété par des zéros
00045
[X]
entier en hexadécimal
2D
[dd/MM/yyyy], [HH:mm:ss]
date et heure (MM : mois, mm : minutes)
03/10/2026, 14:05:09
[dddd d MMMM yyyy]
jour et mois en toutes lettres
samedi 3 octobre 2026
  • les nombres et les dates sont mis en forme selon la culture courante, ici fr-FR : virgule décimale, espace comme séparateur des milliers, noms des jours et des mois en français ;
  • ligne 16 : une expression conditionnelle placée dans une interpolation doit être entourée de parenthèses, car le caractère [:] y introduirait un format ;
  • ligne 19 : la notation [{0}], [{1}]… de [string.Format] est celle qu'utilisait le cours de 2008 (et [Console.WriteLine], qui accepte les mêmes paramètres). On la rencontre encore souvent ; l'interpolation est plus lisible ;
  • lignes 24-26 : les largeurs permettent d'afficher des données en colonnes ;
  • ligne 30 : une chaîne verbatim [@"..."] ne traite pas [] comme un caractère d'échappement. Elle est pratique pour les chemins Windows et les expressions régulières ;
  • lignes 34-39 : une chaîne brute (C# 11) est délimitée par au moins trois guillemets. Elle peut s'étendre sur plusieurs lignes et contenir des guillemets et des [] sans échappement. L'indentation du guillemet fermant est retirée de chaque ligne : le texte JSON s'affiche sans les 4 espaces de la marge du code ;
  • ligne 42 : une chaîne brute peut être interpolée. Avec [$$], une expression interpolée s'écrit entre doubles accolades [{{nom}}], et les accolades simples, nombreuses dans un texte JSON, restent des caractères ordinaires.

5.2.5. La classe System.Text.StringBuilder

Nous avons dit que les méthodes de la classe [String] qui s'appliquent à une chaîne [s1] rendent une autre chaîne [s2]. La classe [System.Text.StringBuilder] permet de modifier une chaîne sans créer de nouvelle chaîne à chaque opération. Cela améliore les performances lorsqu'on construit une chaîne par de nombreux ajouts successifs, en évitant la multiplication de chaînes à durée de vie très limitée.

La classe admet divers constructeurs :

constructeur
rôle
[StringBuilder()]
constructeur par défaut
[StringBuilder(string value)]
construction et initialisation avec [value]
[StringBuilder(string value, int capacity)]
construction et initialisation avec [value], avec une capacité initiale de [capacity] caractères

Un objet [StringBuilder] stocke ses caractères dans une zone mémoire dont la taille, sa capacité, est ajustée automatiquement lorsque le texte grandit. Par défaut, la capacité initiale est de 16 caractères. Le troisième constructeur permet de la choisir. Le programme [chaines-05.cs] illustre cette notion de capacité :

using System.Text;

// taille et capacité d'un StringBuilder
var str = new StringBuilder("test");
Console.WriteLine($"taille={str.Length}, capacité={str.Capacity}");
for (int i = 0; i < 10; i++)
{
    str.Append("test");
    Console.WriteLine($"taille={str.Length}, capacité={str.Capacity}");
}
// un StringBuilder de capacité initiale 10
var str2 = new StringBuilder("test", 10);
Console.WriteLine($"taille={str2.Length}, capacité={str2.Capacity}");
for (int i = 0; i < 10; i++)
{
    str2.Append("test");
    Console.WriteLine($"taille={str2.Length}, capacité={str2.Capacity}");
}

Résultats :

> dotnet run chaines-05.cs
taille=4, capacité=16
taille=8, capacité=16
taille=12, capacité=16
taille=16, capacité=16
taille=20, capacité=32
taille=24, capacité=32
taille=28, capacité=32
taille=32, capacité=32
taille=36, capacité=64
taille=40, capacité=64
taille=44, capacité=64
taille=4, capacité=10
taille=8, capacité=10
taille=12, capacité=20
taille=16, capacité=20
taille=20, capacité=20
taille=24, capacité=40
taille=28, capacité=40
taille=32, capacité=40
taille=36, capacité=40
taille=40, capacité=40
taille=44, capacité=80

Commentaires :

  • ligne 4 : création d'un objet [StringBuilder] de capacité initiale 16 ;
  • ligne 5 : [Length] est le nombre actuel de caractères ; [Capacity] le nombre de caractères que l'objet peut contenir avant de devoir agrandir sa zone mémoire ;
  • ligne 8 : [Append] ajoute une chaîne à la fin du texte ;
  • ligne 12 : création d'un objet [StringBuilder] de capacité initiale 10.

Les résultats montrent que la capacité double lorsqu'elle est insuffisante : 16, 32, 64 (lignes 6 et 10 des résultats), et 10, 20, 40, 80 pour le second objet. Ce sont les mêmes résultats qu'en 2008. Si l'on connaît à l'avance la taille approximative du texte, la fixer dès la construction évite les agrandissements successifs.

Voici quelques-unes des méthodes de la classe :

méthode
rôle
[StringBuilder Append(string value)]
ajoute [value] à la fin du texte. Surchargée pour accepter les types [int], [double], [decimal], [char]…, et une chaîne interpolée
[StringBuilder AppendLine()]
ajoute une fin de ligne (une surcharge ajoute une chaîne suivie d'une fin de ligne)
[StringBuilder AppendJoin(string separator, params string[] values)]
ajoute les éléments de [values] séparés par [separator]
[StringBuilder Insert(int index, string value)]
insère [value] à la position [index] ; surchargée comme [Append]
[StringBuilder Remove(int index, int length)]
supprime [length] caractères à partir de la position [index]
[StringBuilder Replace(string oldValue, string newValue)]
remplace [oldValue] par [newValue] ; une surcharge remplace un caractère
[string ToString()]
rend le texte sous la forme d'une chaîne [string]

Le programme [chaines-06.cs] les utilise :

using System.Text;

// les méthodes de StringBuilder rendent l'objet StringBuilder lui-même : on peut les chaîner
var str3 = new StringBuilder("test");
Console.WriteLine(str3.Append("abCD").Insert(2, "xyZT").Remove(0, 2).Replace("xy", "XY"));

// construire une chaîne par morceaux
var lignes = new StringBuilder();
for (int i = 1; i <= 3; i++)
{
    lignes.Append("ligne ").Append(i).AppendLine();
}
// AppendJoin : l'équivalent de string.Join
lignes.AppendJoin(", ", "a", "b", "c").AppendLine();
// Append accepte aussi une chaîne interpolée
int total = 42;
lignes.Append($"total={total,6:F1}");
// la chaîne finale
string resultat = lignes.ToString();
Console.WriteLine(resultat);
Console.WriteLine($"{resultat.Length} caractères");

Résultats :

1
2
3
4
5
6
7
8
> dotnet run chaines-06.cs
XYZTstabCD
ligne 1
ligne 2
ligne 3
a, b, c
total=  42,0
44 caractères

Commentaires :

  • ligne 5 : les méthodes de modification rendent l'objet [StringBuilder] lui-même, ce qui permet de les enchaîner. Suivons les étapes : « test » devient « testabCD » ([Append]), puis « texyZTstabCD » ([Insert] en position 2), puis « xyZTstabCD » ([Remove] des 2 premiers caractères), enfin « XYZTstabCD » ([Replace]). [Console.WriteLine] affiche l'objet en appelant sa méthode [ToString] ;
  • lignes 9-11 : la construction d'un texte de plusieurs lignes par ajouts successifs : c'est l'usage typique de [StringBuilder] ;
  • ligne 17 : [Append] accepte une chaîne interpolée, avec largeur et format ;
  • ligne 19 : le texte final est obtenu sous la forme d'une chaîne [string].

Pour quelques concaténations, l'opérateur [+] ou l'interpolation suffisent : le compilateur les optimise. [StringBuilder] s'impose lorsque le nombre d'ajouts est grand ou inconnu, typiquement dans une boucle.

5.2.6. Les vues sur une chaîne : Span<char>

.NET Core 2.1 (2018) a introduit les types [Span<T>] et [ReadOnlySpan<T>]. Un objet [ReadOnlySpan<char>] est une vue sur une suite de caractères existante, par exemple une partie d'une chaîne : il ne contient pas de copie des caractères, seulement un moyen d'y accéder. Extraire une partie d'une chaîne avec une vue ne crée donc pas de nouvelle chaîne. Le programme [chaines-07.cs] compare les deux façons d'analyser une ligne « nom:prénom:âge » :

// ReadOnlySpan<char> : une « vue » sur une partie d'une chaîne, sans copie
string ligne = "dupont:jean:45";

// avec Substring : chaque champ est une nouvelle chaîne allouée en mémoire
int p1 = ligne.IndexOf(':');
int p2 = ligne.LastIndexOf(':');
string nom = ligne.Substring(0, p1);
int age = int.Parse(ligne.Substring(p2 + 1));
Console.WriteLine($"Substring : nom={nom}, âge={age}");

// avec une vue : aucune chaîne intermédiaire n'est créée
ReadOnlySpan<char> vue = ligne.AsSpan();
ReadOnlySpan<char> vueNom = vue[..p1];          // les caractères 0 à p1-1
ReadOnlySpan<char> vueAge = vue[(p2 + 1)..];    // de p2+1 à la fin
// int.Parse accepte une vue
age = int.Parse(vueAge);
Console.WriteLine($"Span : nom={vueNom}, âge={age}, longueur de la vue={vueNom.Length}");
// une vue se compare à une chaîne sans la copier
Console.WriteLine($"le nom commence par \"du\" : {vueNom.StartsWith("du")}");
// on ne crée une chaîne que si on en a vraiment besoin
string prenom = vue[(p1 + 1)..p2].ToString();
Console.WriteLine($"prénom={prenom}");

Résultats :

1
2
3
4
5
> dotnet run chaines-07.cs
Substring : nom=dupont, âge=45
Span : nom=dupont, âge=45, longueur de la vue=6
le nom commence par "du" : True
prénom=jean

Commentaires :

  • lignes 7-8 : chaque appel de [Substring] crée une nouvelle chaîne en mémoire, même si, comme pour l'âge, elle n'est utilisée qu'un instant ;
  • ligne 12 : [AsSpan] rend une vue sur toute la chaîne ;
  • lignes 13-14 : les plages [début..fin] (C# 8), présentées au chapitre 3 pour les tableaux, s'appliquent aux vues : on obtient deux nouvelles vues, sans copie ;
  • ligne 16 : de nombreuses méthodes de .NET acceptent une vue au lieu d'une chaîne ([Parse], [TryParse], [StartsWith], [Contains], [Equals]…) ;
  • ligne 21 : on ne crée une chaîne à partir d'une vue que si on en a réellement besoin.

Les vues sont un outil d'optimisation, utilisé intensivement à l'intérieur de .NET lui-même : c'est une des raisons pour lesquelles les versions récentes sont nettement plus rapides que le .NET Framework de 2008. Une vue ne peut exister que dans la pile d'exécution : elle ne peut pas, par exemple, être le champ d'une classe. Pour un programme ordinaire, [string] et [Substring] conviennent parfaitement : on n'utilisera les vues que dans le code dont on a mesuré qu'il est critique pour les performances. Nous n'irons pas plus loin.

5.3. Les tableaux

5.3.1. La classe Array

Les tableaux dérivent de la classe [System.Array]. Celle-ci possède diverses méthodes pour trier un tableau, rechercher un élément dans un tableau, redimensionner un tableau... Nous présentons certaines de ses propriétés et méthodes. Elles sont quasiment toutes surchargées, c'est-à-dire qu'elles existent en différentes variantes. Tout tableau en hérite.

Propriétés :

propriété
rôle
[public int Length { get; }]
nombre total d'éléments du tableau, quel que soit son nombre de dimensions
[public int Rank { get; }]
nombre de dimensions du tableau

Méthodes :

méthode
rôle
[public static int BinarySearch<T>(T[] array, T value)]
rend la position de [value] dans [array], qui doit être trié ; un nombre négatif si [value] est absent
[public static void Clear(Array array, int index, int length)]
met les [length] éléments de [array] commençant au n° [index] à leur valeur par défaut : 0 pour les nombres, [false] pour les booléens, [null] pour les références
[public static void Copy(Array source, Array destination, int length)]
copie les [length] premiers éléments de [source] dans [destination]
[public static void Fill<T>(T[] array, T value)]
affecte [value] à tous les éléments de [array]
[public int GetLength(int dimension)]
nombre d'éléments de la dimension n° [dimension] du tableau
[public static int IndexOf<T>(T[] array, T value)]
rend la position de [value] dans [array], ou -1 si [value] n'est pas trouvé ; [LastIndexOf] rend la dernière position
[public static void Resize<T>(ref T[]? array, int newSize)]
redimensionne [array] à [newSize] éléments ; les éléments déjà présents sont conservés
[public static void Reverse(Array array)]
inverse l'ordre des éléments de [array]
[public static void Sort<T>(T[] array)]
trie [array] selon l'ordre naturel de ses éléments ; une surcharge reçoit un objet [IComparer<T>] qui définit l'ordre (chapitre 4)

Le programme [tableaux-01.cs] illustre l'utilisation de certaines méthodes de la classe [Array] :

// la classe Array : redimensionner, trier, rechercher

// lecture des éléments d'un tableau tapés au clavier
double[] elements = Saisie();
// affichage du tableau non trié
Affiche("Tableau non trié", elements);
// recherche linéaire dans le tableau non trié
Recherche(elements, TypeRecherche.Lineaire);
// tri du tableau
Array.Sort(elements);
// affichage du tableau trié
Affiche("Tableau trié", elements);
// recherche dichotomique dans le tableau trié
Recherche(elements, TypeRecherche.Dichotomique);

// saisie des valeurs du tableau : rend le tableau créé
static double[] Saisie()
{
    // au départ, le tableau est vide
    double[] elements = [];
    // boucle de saisie des éléments du tableau
    while (true)
    {
        Console.Write($"Elément (réel) {elements.Length} du tableau (rien pour terminer) : ");
        string reponse = (Console.ReadLine() ?? "").Trim();
        // fin de saisie si chaîne vide
        if (reponse == "")
        {
            break;
        }
        // vérification de la saisie
        if (!double.TryParse(reponse, out double element))
        {
            Console.WriteLine("Saisie incorrecte, recommencez");
            continue;
        }
        // un élément de plus : on agrandit le tableau d'une case
        Array.Resize(ref elements, elements.Length + 1);
        // et on y range le nouvel élément
        elements[^1] = element;
    }
    return elements;
}

// méthode générique pour afficher les éléments d'un tableau
static void Affiche<T>(string texte, T[] elements)
{
    Console.WriteLine(texte.PadRight(50, '-'));
    foreach (T element in elements)
    {
        Console.WriteLine(element);
    }
}

// recherche d'éléments tapés au clavier dans le tableau
static void Recherche(double[] elements, TypeRecherche type)
{
    while (true)
    {
        Console.Write("Elément cherché (rien pour arrêter) : ");
        string reponse = (Console.ReadLine() ?? "").Trim();
        // fini ?
        if (reponse == "")
        {
            break;
        }
        // vérification
        if (!double.TryParse(reponse, out double element))
        {
            Console.WriteLine("Erreur, recommencez...");
            continue;
        }
        // on cherche l'élément dans le tableau
        int i = type == TypeRecherche.Dichotomique
            ? Array.BinarySearch(elements, element)    // tableau trié
            : Array.IndexOf(elements, element);        // tableau quelconque
        // affichage de la réponse
        Console.WriteLine(i >= 0 ? $"Trouvé en position {i}" : $"Pas dans le tableau ({i})");
    }
}

// type de recherche
enum TypeRecherche { Lineaire, Dichotomique }

Commentaires :

  • lignes 17-42 : la méthode [Saisie] saisit les éléments d'un tableau tapés au clavier. Comme on ne connaît pas à l'avance le nombre d'éléments, on part d'un tableau vide (ligne 20) et on l'agrandit d'une case à chaque nouvel élément (ligne 38). [Resize] crée en fait un nouveau tableau, y recopie les éléments de l'ancien et met sa référence dans la variable passée par [ref]. Un algorithme plus efficace allouerait la place par groupes de N éléments. Un tableau n'est pas fait pour être redimensionné : ce cas est mieux traité avec une liste [List<T>], comme nous le verrons bientôt ;
  • ligne 40 : [^1] désigne le dernier élément du tableau (index depuis la fin, C# 8) ;
  • le cours de 2008 faisait de [Saisie] une méthode à paramètre [out] ; la version moderne rend le tableau créé, ce qui est plus naturel ;
  • lignes 56-78 : la méthode [Recherche] cherche dans le tableau des éléments tapés au clavier. Pour un tableau non trié, on fait une recherche linéaire avec [IndexOf] (ligne 76), qui examine les éléments un par un. Pour un tableau trié, on fait une recherche dichotomique avec [BinarySearch] (ligne 75), qui divise à chaque étape par deux la zone de recherche : elle trouve un élément parmi un million en une vingtaine de comparaisons ;
  • ligne 10 : on trie le tableau. On utilise ici une variante de [Sort] qui n'a qu'un paramètre, le tableau à trier. La relation d'ordre utilisée pour comparer les éléments du tableau est alors celle, implicite, de ces éléments : ici, l'ordre naturel des nombres ;
  • lignes 46-51 : une méthode générique, qui affiche un tableau d'éléments de type quelconque [T].

Résultats :

> dotnet run tableaux-01.cs
Elément (réel) 0 du tableau (rien pour terminer) : 3,6
Elément (réel) 1 du tableau (rien pour terminer) : 7,4
Elément (réel) 2 du tableau (rien pour terminer) : -1,5
Elément (réel) 3 du tableau (rien pour terminer) : x
Saisie incorrecte, recommencez
Elément (réel) 3 du tableau (rien pour terminer) : -7
Elément (réel) 4 du tableau (rien pour terminer) : 
Tableau non trié----------------------------------
3,6
7,4
-1,5
-7
Elément cherché (rien pour arrêter) : 7,4
Trouvé en position 1
Elément cherché (rien pour arrêter) : 0
Pas dans le tableau (-1)
Elément cherché (rien pour arrêter) : 
Tableau trié--------------------------------------
-7
-1,5
3,6
7,4
Elément cherché (rien pour arrêter) : 7,4
Trouvé en position 3
Elément cherché (rien pour arrêter) : 0
Pas dans le tableau (-3)
Elément cherché (rien pour arrêter) : 

Lorsque l'élément cherché est absent, [IndexOf] rend -1 et [BinarySearch] un nombre négatif (ligne 27) : c'est le complément à un ([~i]) de la position où il faudrait insérer l'élément pour garder le tableau trié. Ici [~(-3)] vaut 2 : le nombre 0 devrait être inséré en position 2, entre -1,5 et 3,6. Attention : [BinarySearch] sur un tableau non trié rend un résultat sans signification.

5.3.2. D'autres méthodes de la classe Array

Le programme [tableaux-02.cs] présente d'autres méthodes de la classe [Array] :

// d'autres méthodes de la classe Array
int[] nombres = [40, 10, 30, 20, 50];
Affiche("nombres", nombres);

// Reverse : inverse l'ordre des éléments, en place
Array.Reverse(nombres);
Affiche("après Reverse", nombres);

// Sort sur une partie du tableau : 3 éléments à partir de l'indice 1
Array.Sort(nombres, 1, 3);
Affiche("après Sort(nombres, 1, 3)", nombres);

// Copy : copie de 3 éléments dans un autre tableau
int[] copie = new int[5];
Array.Copy(nombres, copie, 3);
Affiche("copie", copie);

// Fill : remplit le tableau avec une valeur ; Clear : remet les éléments à leur valeur par défaut
Array.Fill(copie, 7);
Affiche("après Fill(copie, 7)", copie);
Array.Clear(copie, 1, 2);
Affiche("après Clear(copie, 1, 2)", copie);

// IndexOf, LastIndexOf sur un tableau de chaînes
string[] couleurs = ["rouge", "vert", "bleu", "vert"];
Console.WriteLine($"IndexOf(vert)={Array.IndexOf(couleurs, "vert")}, LastIndexOf(vert)={Array.LastIndexOf(couleurs, "vert")}, IndexOf(noir)={Array.IndexOf(couleurs, "noir")}");

// tri d'un tableau de chaînes : ordre alphabétique de la culture courante
Array.Sort(couleurs);
Affiche("couleurs triées", couleurs);

// un tableau à deux dimensions : Rank, Length, GetLength
int[,] matrice = new int[2, 3];
Console.WriteLine($"Rank={matrice.Rank}, Length={matrice.Length}, GetLength(0)={matrice.GetLength(0)}, GetLength(1)={matrice.GetLength(1)}");

// affichage d'un tableau sur une ligne
static void Affiche<T>(string texte, T[] elements)
{
    Console.WriteLine($"{texte,-28}: [{string.Join(", ", elements)}]");
}

Résultats :

> dotnet run tableaux-02.cs
nombres                     : [40, 10, 30, 20, 50]
après Reverse               : [50, 20, 30, 10, 40]
après Sort(nombres, 1, 3)   : [50, 10, 20, 30, 40]
copie                       : [50, 10, 20, 0, 0]
après Fill(copie, 7)        : [7, 7, 7, 7, 7]
après Clear(copie, 1, 2)    : [7, 0, 0, 7, 7]
IndexOf(vert)=1, LastIndexOf(vert)=3, IndexOf(noir)=-1
couleurs triées             : [bleu, rouge, vert, vert]
Rank=2, Length=6, GetLength(0)=2, GetLength(1)=3

Commentaires :

  • ligne 6 : [Reverse] inverse l'ordre des éléments du tableau lui-même ;
  • ligne 10 : on ne trie que 3 éléments à partir de la position 1 : 20, 30, 10 deviennent 10, 20, 30 ;
  • ligne 15 : copie des 3 premiers éléments de [nombres] dans [copie], dont les autres éléments gardent leur valeur initiale 0 ;
  • lignes 19-21 : [Fill] remplit le tableau, [Clear] remet 2 éléments à 0 à partir de la position 1 ;
  • ligne 29 : les chaînes sont triées selon l'ordre alphabétique de la culture courante ;
  • ligne 34 : pour un tableau à deux dimensions, [Rank] vaut 2, [Length] est le nombre total d'éléments et [GetLength(i)] le nombre d'éléments de la dimension i ;
  • ligne 39 : [string.Join] permet d'afficher un tableau sur une ligne.

LINQ, que nous présenterons plus loin, offre d'autres façons de chercher, trier ou transformer les éléments d'un tableau.

5.4. Les collections génériques

Outre le tableau, il existe diverses classes pour stocker des collections d'éléments. Les collections génériques, apparues avec .NET 2.0, se trouvent dans l'espace de noms [System.Collections.Generic], qui fait partie des [using] implicites. Nous présenterons d'abord deux collections fréquemment utilisées, la liste et le dictionnaire, puis d'autres plus rapidement.

L'espace de noms [System.Collections] contient les anciennes versions non génériques de ces collections, datant de .NET 1.0 : [ArrayList] (une liste), [Hashtable] (un dictionnaire)... Le cours de 2008 présentait encore [ArrayList]. Ces classes sont aujourd'hui obsolètes : elles stockent des références de type [object], ce qui oblige à transtyper les éléments lus et à encapsuler (boxing) les nombres dans des objets, avec un coût en mémoire et en temps. Les collections génériques n'ont aucun de ces défauts : une [List<int>] range ses entiers directement dans un tableau [int[]] interne, et le compilateur vérifie le type des éléments ajoutés. On ne doit plus utiliser [ArrayList] et [Hashtable] dans du code nouveau.

5.4.1. La classe générique List<T>

La classe [System.Collections.Generic.List<T>] permet d'implémenter des collections d'objets de type [T] dont la taille varie au cours de l'exécution du programme. Un objet de type [List<T>] se manipule presque comme un tableau. Ainsi l'élément i d'une liste [l] est-il noté [l[i]].

Pour une liste [List<T>] où [T] est une classe, la liste stocke les références des objets de type [T], comme un tableau d'objets ; si [T] est un type valeur ([int], [double], structure…), les valeurs elles-mêmes sont stockées dans la liste.

Voici quelques-unes des propriétés et méthodes des listes génériques.

Propriétés :

propriété
rôle
[public int Count { get; }]
nombre d'éléments de la liste
[public int Capacity { get; set; }]
nombre d'éléments que la liste peut contenir avant d'être agrandie. Cet agrandissement est automatique. Cette notion de capacité est analogue à celle décrite pour la classe [StringBuilder]

Méthodes :

méthode
rôle
[public void Add(T item)]
ajoute [item] à la fin de la liste
[public void AddRange(IEnumerable<T> collection)]
ajoute les éléments de [collection] à la fin de la liste
[public int BinarySearch(T item)]
rend la position de [item] dans la liste triée s'il s'y trouve, sinon un nombre négatif
[public void Clear()]
supprime tous les éléments de la liste
[public bool Contains(T item)]
rend [true] si [item] est dans la liste, [false] sinon
[public List<T> GetRange(int index, int count)]
rend une nouvelle liste formée de [count] éléments à partir de la position [index]
[public int IndexOf(T item)]
rend la position de [item] dans la liste, ou -1 si [item] n'est pas trouvé
[public void Insert(int index, T item)]
insère [item] à la position [index] de la liste
[public bool Remove(T item)]
supprime la première occurrence de [item] de la liste ; rend [true] si l'opération réussit, [false] sinon
[public void RemoveAt(int index)]
supprime l'élément n° [index] de la liste
[public void Reverse()]
inverse l'ordre des éléments de la liste
[public void Sort()]
trie la liste selon l'ordre naturel de ses éléments ; une surcharge reçoit un [IComparer<T>]
[public T[] ToArray()]
rend les éléments de la liste sous forme de tableau

Reprenons l'exemple traité précédemment avec un tableau et traitons-le maintenant avec un objet de type [List<double>]. Parce que la liste est un objet proche du tableau, le code change peu. C'est le programme [collections-01.cs] :

// la classe List<T> : le programme tableaux-01.cs réécrit avec une liste

// lecture des éléments d'une liste tapés au clavier
List<double> elements = Saisie();
// nombre d'éléments et capacité
Console.WriteLine($"La liste a {elements.Count} éléments et une capacité de {elements.Capacity} éléments");
// affichage de la liste non triée
Affiche("Liste non triée", elements);
// recherche linéaire dans la liste non triée
Recherche(elements, TypeRecherche.Lineaire);
// tri de la liste
elements.Sort();
// affichage de la liste triée
Affiche("Liste triée", elements);
// recherche dichotomique dans la liste triée
Recherche(elements, TypeRecherche.Dichotomique);

// saisie des valeurs de la liste : rend la liste créée
static List<double> Saisie()
{
    // au départ, la liste est vide
    List<double> elements = new();
    // boucle de saisie des éléments de la liste
    while (true)
    {
        Console.Write($"Elément (réel) {elements.Count} de la liste (rien pour terminer) : ");
        string reponse = (Console.ReadLine() ?? "").Trim();
        // fin de saisie si chaîne vide
        if (reponse == "")
        {
            break;
        }
        // vérification de la saisie
        if (!double.TryParse(reponse, out double element))
        {
            Console.WriteLine("Saisie incorrecte, recommencez");
            continue;
        }
        // un élément de plus dans la liste : elle s'agrandit toute seule
        elements.Add(element);
    }
    return elements;
}

// méthode générique pour afficher les éléments d'une séquence quelconque
static void Affiche<T>(string texte, IEnumerable<T> elements)
{
    Console.WriteLine(texte.PadRight(50, '-'));
    foreach (T element in elements)
    {
        Console.WriteLine(element);
    }
}

// recherche d'éléments tapés au clavier dans la liste
static void Recherche(List<double> elements, TypeRecherche type)
{
    while (true)
    {
        Console.Write("Elément cherché (rien pour arrêter) : ");
        string reponse = (Console.ReadLine() ?? "").Trim();
        // fini ?
        if (reponse == "")
        {
            break;
        }
        // vérification
        if (!double.TryParse(reponse, out double element))
        {
            Console.WriteLine("Erreur, recommencez...");
            continue;
        }
        // on cherche l'élément dans la liste
        int i = type == TypeRecherche.Dichotomique
            ? elements.BinarySearch(element)    // liste triée
            : elements.IndexOf(element);        // liste quelconque
        // affichage de la réponse
        Console.WriteLine(i >= 0 ? $"Trouvé en position {i}" : $"Pas dans la liste ({i})");
    }
}

// type de recherche
enum TypeRecherche { Lineaire, Dichotomique }

Commentaires :

  • ligne 22 : la liste est créée vide. [new()] (C# 9) est une forme abrégée de [new List<double>()] : le type est déduit de la déclaration de la variable ;
  • ligne 40 : il n'y a plus de redimensionnement à gérer : la liste s'agrandit d'elle-même ;
  • ligne 6 : le nombre d'éléments de la liste et sa capacité ;
  • lignes 12, 75, 76 : les méthodes [Sort], [BinarySearch] et [IndexOf] sont maintenant des méthodes d'instance de la liste, alors que celles de [Array] sont des méthodes statiques ;
  • lignes 46-51 : la méthode générique [Affiche<T>] admet deux paramètres :
    • le premier est un texte à écrire ;
    • le second est un objet implémentant l'interface générique [IEnumerable<T>] :
1
2
3
4
public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

La structure [foreach (T element in elements)] est valide pour tout objet [elements] implémentant l'interface [IEnumerable<T>]. Les tableaux et les listes implémentent cette interface. Aussi la méthode [Affiche] convient-elle aussi bien pour afficher des tableaux que des listes. Nous reviendrons sur cette interface, qui est au cœur de LINQ.

Résultats :

> dotnet run collections-01.cs
Elément (réel) 0 de la liste (rien pour terminer) : 3,6
Elément (réel) 1 de la liste (rien pour terminer) : 7,4
Elément (réel) 2 de la liste (rien pour terminer) : -1,5
Elément (réel) 3 de la liste (rien pour terminer) : -7
Elément (réel) 4 de la liste (rien pour terminer) : 
La liste a 4 éléments et une capacité de 4 éléments
Liste non triée-----------------------------------
3,6
7,4
-1,5
-7
Elément cherché (rien pour arrêter) : 7,4
Trouvé en position 1
Elément cherché (rien pour arrêter) : 0
Pas dans la liste (-1)
Elément cherché (rien pour arrêter) : 
Liste triée---------------------------------------
-7
-1,5
3,6
7,4
Elément cherché (rien pour arrêter) : 7,4
Trouvé en position 3
Elément cherché (rien pour arrêter) : 0
Pas dans la liste (-3)
Elément cherché (rien pour arrêter) : 

Les résultats sont les mêmes qu'avec le tableau. La capacité de la liste est ici de 4 (ligne 7) : partie de 0, elle est passée à 4 au premier ajout ; elle serait passée à 8 au cinquième, en doublant comme celle d'un [StringBuilder].

5.4.2. Méthodes de List<T> et expressions de collection

Le programme [collections-02.cs] utilise les autres méthodes de la liste et les expressions de collection de C# 12, déjà rencontrées avec les tableaux au chapitre 3 :

// les principales méthodes de List<T> et les expressions de collection

// une liste initialisée par une expression de collection (C# 12)
List<string> villes = ["Paris", "Lyon", "Angers"];
Affiche("départ", villes);

// ajouts
villes.Add("Nantes");                       // à la fin
villes.Insert(1, "Brest");                  // en position 1
villes.AddRange(["Lille", "Tours"]);        // plusieurs éléments à la fin
Affiche("après Add, Insert, AddRange", villes);

// accès par indice, en lecture et en écriture
villes[0] = "Marseille";
Console.WriteLine($"villes[0]={villes[0]}, villes[^1]={villes[^1]}, Count={villes.Count}");

// recherche
Console.WriteLine($"Contains(\"Lyon\")={villes.Contains("Lyon")}, IndexOf(\"Angers\")={villes.IndexOf("Angers")}, IndexOf(\"Nice\")={villes.IndexOf("Nice")}");

// suppressions
bool supprime = villes.Remove("Lyon");      // par valeur
villes.RemoveAt(0);                         // par position
Affiche($"après Remove ({supprime}), RemoveAt(0)", villes);

// tri, inversion, plage d'éléments
villes.Sort();
Affiche("après Sort", villes);
villes.Reverse();
Affiche("après Reverse", villes);
List<string> extrait = villes.GetRange(1, 2);
Affiche("GetRange(1, 2)", extrait);

// conversion en tableau et retour
string[] tableau = villes.ToArray();
List<string> autre = new(tableau);
Console.WriteLine($"tableau de {tableau.Length} éléments, nouvelle liste de {autre.Count} éléments");

// l'opérateur .. (spread) insère les éléments d'une collection dans une autre
int[] petits = [1, 2, 3];
List<int> nombres = [0, .. petits, 10, .. petits];
Affiche("nombres", nombres);
// la même syntaxe crée un tableau, une liste, ou une collection vide
int[] vide = [];
Console.WriteLine($"vide.Length={vide.Length}");

// Clear vide la liste
villes.Clear();
Console.WriteLine($"après Clear : Count={villes.Count}");

static void Affiche<T>(string texte, List<T> liste)
{
    Console.WriteLine($"{texte,-34}: [{string.Join(", ", liste)}]");
}

Résultats :

> dotnet run collections-02.cs
départ                            : [Paris, Lyon, Angers]
après Add, Insert, AddRange       : [Paris, Brest, Lyon, Angers, Nantes, Lille, Tours]
villes[0]=Marseille, villes[^1]=Tours, Count=7
Contains("Lyon")=True, IndexOf("Angers")=3, IndexOf("Nice")=-1
après Remove (True), RemoveAt(0)  : [Brest, Angers, Nantes, Lille, Tours]
après Sort                        : [Angers, Brest, Lille, Nantes, Tours]
après Reverse                     : [Tours, Nantes, Lille, Brest, Angers]
GetRange(1, 2)                    : [Nantes, Lille]
tableau de 5 éléments, nouvelle liste de 5 éléments
nombres                           : [0, 1, 2, 3, 10, 1, 2, 3]
vide.Length=0
après Clear : Count=0

Commentaires :

  • ligne 4 : une expression de collection [a, b, c] initialise une liste comme un tableau ;
  • lignes 8-10 : trois façons d'ajouter des éléments ;
  • ligne 14 : l'élément n° 0 est accessible en lecture et en écriture ; [villes[^1]] est le dernier élément, comme pour un tableau ;
  • ligne 21 : [Remove] rend [true] si l'élément a été trouvé et supprimé ;
  • ligne 30 : une nouvelle liste formée de 2 éléments à partir de la position 1 ;
  • lignes 34-35 : passage d'une liste à un tableau, et d'un tableau (ou de toute séquence) à une liste ;
  • ligne 40 : dans une expression de collection, l'opérateur [..] (spread) insère tous les éléments d'une autre collection ;
  • ligne 43 : [] désigne une collection vide du type attendu.

5.4.3. La classe Dictionary<TKey,TValue>

La classe [System.Collections.Generic.Dictionary<TKey,TValue>] permet d'implémenter un dictionnaire. On peut voir un dictionnaire comme un tableau à deux colonnes :

clé
valeur
clé1
valeur1
clé2
valeur2
...
...

Dans la classe [Dictionary<TKey,TValue>], les clés sont de type [TKey], les valeurs de type [TValue]. Les clés sont uniques : il ne peut y avoir deux clés identiques. Un tel dictionnaire pourrait ressembler à ceci si les types [TKey] et [TValue] désignaient des classes :

Image

La valeur associée à la clé [c] d'un dictionnaire [d] est obtenue par la notation [d[c]]. Cette valeur est en lecture et écriture. Ainsi on peut écrire :

1
2
3
4
5
TValue v = ...;
TKey c = ...;
Dictionary<TKey, TValue> d = new();
d[c] = v;
v = d[c];

En écriture, [d[c] = v] ajoute le couple (c, v) si la clé [c] n'existe pas, et remplace la valeur associée à [c] sinon. En lecture, si la clé [c] n'existe pas dans le dictionnaire [d], la notation [d[c]] lance une exception [KeyNotFoundException].

Les méthodes et propriétés principales de la classe [Dictionary<TKey,TValue>] sont les suivantes.

Propriétés :

propriété
rôle
[public int Count { get; }]
nombre d'entrées (clé, valeur) du dictionnaire
[public KeyCollection Keys { get; }]
collection des clés du dictionnaire
[public ValueCollection Values { get; }]
collection des valeurs du dictionnaire

Méthodes :

méthode
rôle
[public void Add(TKey key, TValue value)]
ajoute le couple (key, value) au dictionnaire ; lance une exception si la clé existe déjà
[public bool TryAdd(TKey key, TValue value)]
idem, mais rend [false] au lieu de lancer une exception
[public void Clear()]
supprime tous les couples du dictionnaire
[public bool ContainsKey(TKey key)]
rend [true] si [key] est une clé du dictionnaire, [false] sinon
[public bool ContainsValue(TValue value)]
rend [true] si [value] est une valeur du dictionnaire, [false] sinon
[public bool Remove(TKey key)]
supprime du dictionnaire le couple de clé [key] ; rend [true] si l'opération réussit, [false] sinon
[public bool TryGetValue(TKey key, out TValue value)]
rend [true] et met dans [value] la valeur associée à la clé [key] si cette dernière existe ; rend [false] sinon, et [value] reçoit la valeur par défaut du type [TValue]

Considérons le programme exemple [collections-03.cs] :

// la classe Dictionary<TKey,TValue>

// création d'un dictionnaire <string,int> à partir d'un tableau de chaînes "nom:âge"
string[] liste = ["jean:20", "paul:18", "mélanie:10", "violette:15"];
Dictionary<string, int> dico = new();
foreach (string element in liste)
{
    string[] champs = element.Split(':');
    dico[champs[0]] = int.Parse(champs[1]);
}
// nombre d'éléments du dictionnaire
Console.WriteLine($"Le dictionnaire a {dico.Count} éléments");
// liste des clés
Affiche("[Liste des clés]", dico.Keys);
// liste des valeurs
Affiche("[Liste des valeurs]", dico.Values);
// liste des clés et valeurs
Console.WriteLine("[Liste des clés & valeurs]");
foreach (string cle in dico.Keys)
{
    Console.WriteLine($"clé={cle} valeur={dico[cle]}");
}
// on supprime la clé "paul"
Console.WriteLine("[Suppression d'une clé]");
dico.Remove("paul");
// parcours des couples (clé, valeur) avec déconstruction
Console.WriteLine("[Liste des clés & valeurs]");
foreach (var (cle, valeur) in dico)
{
    Console.WriteLine($"clé={cle} valeur={valeur}");
}
// Add refuse une clé existante, alors que dico[clé] = valeur la remplace
try
{
    dico.Add("jean", 21);
}
catch (ArgumentException e)
{
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}
// TryAdd n'ajoute que si la clé est absente
Console.WriteLine($"TryAdd(\"jean\", 21)={dico.TryAdd("jean", 21)}, TryAdd(\"zoé\", 12)={dico.TryAdd("zoé", 12)}");
// la notation dico[clé] lance une exception si la clé est absente
try
{
    Console.WriteLine(dico["x"]);
}
catch (KeyNotFoundException e)
{
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}

// recherche dans le dictionnaire
while (true)
{
    Console.Write("Nom recherché (rien pour arrêter) : ");
    string nomCherche = (Console.ReadLine() ?? "").Trim();
    if (nomCherche == "")
    {
        break;
    }
    // TryGetValue rend true si la clé existe et met la valeur associée dans age
    if (dico.TryGetValue(nomCherche, out int age))
    {
        Console.WriteLine($"{nomCherche},{age}");
    }
    else
    {
        Console.WriteLine($"Nom {nomCherche} inconnu");
    }
}

// méthode générique pour afficher les éléments d'une séquence
static void Affiche<T>(string texte, IEnumerable<T> elements)
{
    Console.WriteLine(texte.PadRight(50, '-'));
    foreach (T element in elements)
    {
        Console.WriteLine(element);
    }
}

Commentaires :

  • ligne 4 : un tableau de chaînes qui va servir à initialiser le dictionnaire <string,int> ;
  • ligne 5 : le dictionnaire, vide ;
  • lignes 6-9 : son initialisation à partir du tableau de chaînes ;
  • ligne 12 : le nombre d'entrées du dictionnaire ;
  • lignes 14-16 : les clés et les valeurs du dictionnaire. [dico.Keys] et [dico.Values] implémentent [IEnumerable<T>] : la méthode [Affiche] déjà rencontrée convient ;
  • ligne 25 : suppression d'une entrée du dictionnaire ;
  • ligne 28 : un dictionnaire est une séquence de couples [KeyValuePair<TKey,TValue>], qui ont une propriété [Key] et une propriété [Value]. Depuis .NET Core 2.0, un tel couple se déconstruit en deux variables, comme un tuple (chapitre 3) ;
  • lignes 35-42 : [Add] refuse une clé existante en lançant une exception ; [TryAdd] rend [false] dans ce cas. Les messages des exceptions de .NET sont en anglais : .NET 10 n'est pas fourni avec des messages en français ;
  • ligne 46 : la lecture d'une clé absente lance une exception [KeyNotFoundException] ;
  • ligne 63 : recherche d'une clé dans le dictionnaire. [TryGetValue] rend [true] si la clé existe. Le cours de 2008 ignorait ce résultat et testait si la valeur rendue était nulle, technique qui ne fonctionnait que parce que la valeur 0 n'était pas dans le dictionnaire. Le test du booléen rendu est la bonne façon de faire.

Résultats :

> dotnet run collections-03.cs
Le dictionnaire a 4 éléments
[Liste des clés]----------------------------------
jean
paul
mélanie
violette
[Liste des valeurs]-------------------------------
20
18
10
15
[Liste des clés & valeurs]
clé=jean valeur=20
clé=paul valeur=18
clé=mélanie valeur=10
clé=violette valeur=15
[Suppression d'une clé]
[Liste des clés & valeurs]
clé=jean valeur=20
clé=mélanie valeur=10
clé=violette valeur=15
ArgumentException : An item with the same key has already been added. Key: jean
TryAdd("jean", 21)=False, TryAdd("zoé", 12)=True
KeyNotFoundException : The given key 'x' was not present in the dictionary.
Nom recherché (rien pour arrêter) : violette
violette,15
Nom recherché (rien pour arrêter) : x
Nom x inconnu
Nom recherché (rien pour arrêter) : jean
jean,20
Nom recherché (rien pour arrêter) : 

Les clés sont ici énumérées dans leur ordre d'ajout, mais ce n'est pas garanti : un [Dictionary] ne maintient aucun ordre. Après des suppressions, un nouvel élément peut prendre la place d'un élément supprimé. Si l'ordre des clés importe, on utilisera un [SortedDictionary], présenté ci-dessous.

5.4.4. D'autres collections génériques

L'espace de noms [System.Collections.Generic] contient d'autres collections, chacune adaptée à un usage particulier :

classe
rôle
[HashSet<T>]
un ensemble : pas de doublons, pas d'ordre, test d'appartenance très rapide ; opérations ensemblistes (union, intersection, différence)
[SortedSet<T>]
un ensemble dont les éléments sont maintenus triés
[Queue<T>]
une file : les éléments sortent dans l'ordre où ils sont entrés (FIFO, first in first out)
[Stack<T>]
une pile : le dernier élément entré est le premier sorti (LIFO, last in first out)
[SortedDictionary<TKey,TValue>]
un dictionnaire dont les clés sont maintenues triées
[PriorityQueue<TElement,TPriority>]
une file de priorité (.NET 6) : l'élément qui sort est celui de plus petite priorité
[LinkedList<T>]
une liste doublement chaînée : insertions et suppressions rapides au milieu de la liste

Le programme [collections-04.cs] en fait un rapide survol :

// un survol d'autres collections génériques

// HashSet<T> : un ensemble, sans doublons ni ordre
HashSet<string> langages = ["C#", "Java", "PHP"];
bool ajoute1 = langages.Add("Python");
bool ajoute2 = langages.Add("Java");             // déjà présent : refusé
Console.WriteLine($"HashSet : Add(Python)={ajoute1}, Add(Java)={ajoute2}, Count={langages.Count}, Contains(PHP)={langages.Contains("PHP")}");
HashSet<string> web = ["PHP", "JavaScript", "Python"];
HashSet<string> communs = new(langages);
communs.IntersectWith(web);                       // intersection
Console.WriteLine($"HashSet : intersection = {{{string.Join(", ", communs)}}}");
communs.UnionWith(["Go"]);                        // union
Console.WriteLine($"HashSet : après union = {{{string.Join(", ", communs)}}}");

// Queue<T> : une file, premier entré, premier sorti
Queue<string> file = new();
file.Enqueue("client1");
file.Enqueue("client2");
file.Enqueue("client3");
Console.WriteLine($"Queue : Peek={file.Peek()}, Dequeue={file.Dequeue()}, Dequeue={file.Dequeue()}, reste {file.Count}");

// Stack<T> : une pile, dernier entré, premier sorti
Stack<int> pile = new();
pile.Push(1);
pile.Push(2);
pile.Push(3);
Console.WriteLine($"Stack : Peek={pile.Peek()}, Pop={pile.Pop()}, Pop={pile.Pop()}, reste {pile.Count}");
// TryPop évite l'exception sur une pile vide
pile.Pop();
Console.WriteLine($"Stack : TryPop sur une pile vide = {pile.TryPop(out int sommet)}");

// SortedDictionary<TKey,TValue> : un dictionnaire dont les clés sont maintenues triées
SortedDictionary<string, int> ages = new()
{
    ["violette"] = 15,
    ["jean"] = 20,
    ["mélanie"] = 10,
    ["paul"] = 18
};
Console.WriteLine($"SortedDictionary : {string.Join(", ", ages)}");

// PriorityQueue<TElement,TPriority> : on sort toujours l'élément de plus petite priorité
PriorityQueue<string, int> urgences = new();
urgences.Enqueue("entorse", 3);
urgences.Enqueue("arrêt cardiaque", 1);
urgences.Enqueue("rhume", 5);
urgences.Enqueue("fracture", 2);
Console.Write("PriorityQueue :");
while (urgences.TryDequeue(out string? patient, out int priorite))
{
    Console.Write($" {patient} ({priorite})");
}
Console.WriteLine();

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run collections-04.cs
HashSet : Add(Python)=True, Add(Java)=False, Count=4, Contains(PHP)=True
HashSet : intersection = {PHP, Python}
HashSet : après union = {Go, PHP, Python}
Queue : Peek=client1, Dequeue=client1, Dequeue=client2, reste 1
Stack : Peek=3, Pop=3, Pop=2, reste 1
Stack : TryPop sur une pile vide = False
SortedDictionary : [jean, 20], [mélanie, 10], [paul, 18], [violette, 15]
PriorityQueue : arrêt cardiaque (1) fracture (2) entorse (3) rhume (5)

Commentaires :

  • ligne 4 : les expressions de collection initialisent aussi un ensemble ;
  • ligne 6 : [Add] rend [false] si l'élément est déjà dans l'ensemble, qui reste inchangé ;
  • lignes 9-12 : [IntersectWith] et [UnionWith] modifient l'ensemble ; on travaille donc sur une copie de [langages]. L'ordre d'affichage des éléments d'un ensemble n'a pas de signification (ligne 4) ;
  • les doubles accolades [{{] et [}}] d'une chaîne interpolée affichent une accolade ;
  • lignes 16-20 : [Enqueue] fait entrer un élément dans la file, [Dequeue] fait sortir le plus ancien, [Peek] le consulte sans le faire sortir ;
  • lignes 23-30 : [Push] empile un élément, [Pop] dépile le plus récent. [Pop] sur une pile vide lance une exception, [TryPop] rend [false] ;
  • lignes 33-40 : un dictionnaire initialisé avec la syntaxe [[clé] = valeur] ; ses couples sont énumérés dans l'ordre des clés ;
  • lignes 43-49 : chaque élément entre dans la file avec une priorité ; les patients sortent par ordre de priorité croissante, quel que soit leur ordre d'arrivée.

5.4.5. Les collections en lecture seule

Une classe qui gère une collection doit souvent la montrer à l'extérieur sans permettre qu'on la modifie. .NET propose pour cela des interfaces en lecture seule : [IReadOnlyCollection<T>] (un [Count] et un [foreach]), [IReadOnlyList<T>] (en plus, un accès par indice) et [IReadOnlyDictionary<TKey,TValue>]. Les classes [List<T>], [T[]] et [Dictionary<TKey,TValue>] les implémentent. Le programme [collections-05.cs] les utilise :

// les collections en lecture seule

var classe = new Classe();
classe.Inscrire("dupont");
classe.Inscrire("martin");
// le code extérieur peut lire la liste des élèves...
IReadOnlyList<string> eleves = classe.Eleves;
Console.WriteLine($"{eleves.Count} élèves : {string.Join(", ", eleves)}, eleves[0]={eleves[0]}");
// ... mais l'interface IReadOnlyList<T> n'a ni Add ni Remove : eleves.Add("x") ne compile pas

// un transtypage vers List<string> permettrait de contourner la protection
if (eleves is List<string> liste)
{
    Console.WriteLine("Eleves est en réalité une List<string> modifiable");
}
// AsReadOnly rend une enveloppe en lecture seule qui ne peut pas être contournée
IReadOnlyList<string> protegee = classe.ElevesProteges;
Console.WriteLine($"ElevesProteges est de type {protegee.GetType().Name}");
try
{
    // ReadOnlyCollection<T> implémente IList<T>, mais ses méthodes de modification lancent une exception
    ((IList<string>)protegee).Add("intrus");
}
catch (NotSupportedException e)
{
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}
// l'enveloppe reflète les modifications faites par la classe propriétaire
classe.Inscrire("durand");
Console.WriteLine($"{protegee.Count} élèves : {string.Join(", ", protegee)}");

// un dictionnaire en lecture seule
IReadOnlyDictionary<string, int> notes = new Dictionary<string, int> { ["dupont"] = 12, ["martin"] = 15 };
Console.WriteLine($"notes[martin]={notes["martin"]}, ContainsKey(durand)={notes.ContainsKey("durand")}");

// une classe qui garde le contrôle de sa liste
class Classe
{
    // la liste est privée : seule la classe peut la modifier
    private readonly List<string> _eleves = [];

    // exposée en lecture seule par une interface
    public IReadOnlyList<string> Eleves => _eleves;
    // exposée par une enveloppe en lecture seule
    public IReadOnlyList<string> ElevesProteges => _eleves.AsReadOnly();

    public void Inscrire(string nom) => _eleves.Add(nom);
}

Résultats :

1
2
3
4
5
6
7
> dotnet run collections-05.cs
2 élèves : dupont, martin, eleves[0]=dupont
Eleves est en réalité une List<string> modifiable
ElevesProteges est de type ReadOnlyCollection`1
NotSupportedException : Collection is read-only.
3 élèves : dupont, martin, durand
notes[martin]=15, ContainsKey(durand)=False

Commentaires :

  • lignes 37-47 : la classe [Classe] gère une liste d'élèves. La liste elle-même est privée ; seule la méthode [Inscrire] permet de la modifier ;
  • ligne 43 : la propriété [Eleves] rend la liste sous le type [IReadOnlyList<string>], qui n'offre que les opérations de lecture : [eleves.Add("x")] provoquerait une erreur de compilation ;
  • ligne 12 : la protection n'est cependant que de surface : l'objet rendu est la liste elle-même, et un transtypage permet de retrouver ses méthodes de modification ;
  • ligne 45 : [AsReadOnly] rend une enveloppe de type [ReadOnlyCollection<T>] autour de la liste. Cette fois, aucune modification n'est possible : ses méthodes de modification, imposées par l'interface [IList<T>], lancent une exception [NotSupportedException] (ligne 5). L'enveloppe ne copie pas la liste : elle voit les modifications faites par la classe propriétaire (ligne 6) ;
  • ligne 33 : un dictionnaire vu à travers l'interface [IReadOnlyDictionary<TKey,TValue>].

Pour des données qui ne doivent jamais changer après leur création, l'espace de noms [System.Collections.Immutable] propose des collections immuables ([ImmutableList<T>], [ImmutableDictionary<TKey,TValue>]…) et [System.Collections.Frozen] des collections figées optimisées pour la lecture ([FrozenDictionary<TKey,TValue>], .NET 8). Nous ne les détaillerons pas.

5.5. LINQ

Le cours de 2008 s'arrêtait au seuil de LINQ, en annonçant ses types anonymes sans les utiliser. LINQ (Language Integrated Query, C# 3, 2007) est un ensemble de méthodes qui permettent d'interroger une collection comme on interroge une base de données : filtrer, trier, regrouper, calculer des totaux... Il s'applique à toute collection implémentant l'interface [IEnumerable<T>] : tableaux, listes, dictionnaires, ensembles, lignes d'un fichier, résultats d'une expression régulière, et, avec Entity Framework Core (chapitre 11), tables d'une base de données. L'espace de noms [System.Linq] fait partie des [using] implicites.

5.5.1. Les séquences IEnumerable<T> et les itérateurs

Une collection qui implémente [IEnumerable<T>] est appelée une séquence : on peut la parcourir avec [foreach]. L'interface ne contient qu'une méthode, [GetEnumerator], qui rend un énumérateur [IEnumerator<T>] : un objet qui avance dans la séquence ([MoveNext]) et donne l'élément courant ([Current]).

Une méthode peut produire une séquence sans construire de collection : c'est un itérateur. Il rend ses éléments un par un avec l'instruction [yield return]. Le programme [linq-01.cs] en donne deux exemples :

// séquences IEnumerable<T> et itérateurs (yield return)

// 1 - foreach sur une séquence produite par un itérateur
Console.WriteLine("[foreach sur Pairs(6)]");
foreach (int n in Pairs(6))
{
    Console.WriteLine($"  reçu {n}");
}

// 2 - ce que fait foreach : il demande un énumérateur à la séquence
Console.WriteLine("[énumération à la main]");
IEnumerable<int> sequence = Pairs(4);
using (IEnumerator<int> enumerateur = sequence.GetEnumerator())
{
    while (enumerateur.MoveNext())
    {
        Console.WriteLine($"  Current={enumerateur.Current}");
    }
}

// 3 - une séquence infinie : elle n'est produite qu'au fur et à mesure des besoins
Console.WriteLine("[séquence infinie]");
foreach (long f in Fibonacci())
{
    if (f > 100)
    {
        break;
    }
    Console.Write($"{f} ");
}
Console.WriteLine();

// 4 - un tableau, une liste, une chaîne sont aussi des IEnumerable<T>
IEnumerable<char> lettres = "abc";
IEnumerable<string> mots = new List<string> { "un", "deux" };
Console.WriteLine($"[autres séquences] {string.Join("-", lettres)} {string.Join("-", mots)}");

// les nombres pairs de 0 à max
static IEnumerable<int> Pairs(int max)
{
    Console.WriteLine("  (début de l'itérateur)");
    for (int i = 0; i <= max; i += 2)
    {
        Console.WriteLine($"  (production de {i})");
        // on rend i au code appelant ; on reprendra ici à la demande suivante
        yield return i;
    }
    Console.WriteLine("  (fin de l'itérateur)");
}

// la suite de Fibonacci, sans fin
static IEnumerable<long> Fibonacci()
{
    long a = 0, b = 1;
    while (true)
    {
        yield return a;
        (a, b) = (b, a + b);
    }
}

Résultats :

> dotnet run linq-01.cs
[foreach sur Pairs(6)]
  (début de l'itérateur)
  (production de 0)
  reçu 0
  (production de 2)
  reçu 2
  (production de 4)
  reçu 4
  (production de 6)
  reçu 6
  (fin de l'itérateur)
[énumération à la main]
  (début de l'itérateur)
  (production de 0)
  Current=0
  (production de 2)
  Current=2
  (production de 4)
  Current=4
  (fin de l'itérateur)
[séquence infinie]
0 1 1 2 3 5 8 13 21 34 55 89 
[autres séquences] a-b-c un-deux

Commentaires :

  • lignes 39-48 : la méthode [Pairs] est un itérateur : son type de retour est [IEnumerable<int>] et elle contient l'instruction [yield return] (ligne 46). Elle ne construit aucune liste : à chaque [yield return], elle rend un élément au code qui la parcourt et se met en pause ; elle reprendra à l'instruction suivante quand on lui demandera l'élément suivant ;
  • lignes 5-7 : les résultats (lignes 3-12) montrent que la production d'un élément par l'itérateur et sa consommation par la boucle [foreach] alternent ;
  • lignes 12-17 : ce que fait réellement [foreach] : il demande un énumérateur à la séquence, appelle [MoveNext] tant que cette méthode rend [true], et lit l'élément courant dans [Current]. L'énumérateur est libéré à la fin par l'instruction [using] (nous y reviendrons avec les fichiers). On remarquera que l'appel [Pairs(4)] de la ligne 12 n'exécute rien : le message « début de l'itérateur » n'apparaît qu'au premier [MoveNext] ;
  • lignes 52-58 : un itérateur peut produire une séquence infinie : il n'en calcule que les éléments demandés. C'est la boucle [foreach] qui décide de s'arrêter (ligne 25). La ligne 58 utilise les tuples du chapitre 3 pour échanger les valeurs ;
  • lignes 34-35 : une chaîne est une séquence de caractères, une liste une séquence de ses éléments. La liste est créée ici avec la syntaxe d'initialiseur de collection [new List<string> { ... }] (C# 3), que l'on rencontre souvent dans le code existant.

Ce mécanisme de production à la demande est celui de toutes les méthodes de LINQ.

5.5.2. Filtrer, projeter, trier

Les exemples LINQ qui suivent travaillent sur une petite liste de personnes. Une personne est représentée par un enregistrement [record] (C# 9), présenté au chapitre 4 : en une ligne, on obtient une classe avec un constructeur, des propriétés en lecture seule, une méthode [ToString] qui affiche les propriétés, et une égalité qui compare les valeurs des propriétés.

Le programme [linq-02.cs] présente les méthodes de base de LINQ :

// LINQ : filtrer (Where), projeter (Select), trier (OrderBy, ThenBy)

// les données : une liste de personnes
List<Personne> personnes =
[
    new("dupont", "jean", 45, "Angers"),
    new("martin", "marie", 17, "Paris"),
    new("durand", "paul", 32, "Angers"),
    new("leroy", "sophie", 28, "Nantes"),
    new("moreau", "luc", 15, "Paris"),
    new("simon", "julie", 52, "Angers"),
    new("laurent", "marc", 32, "Lyon"),
];

// Where : les personnes majeures ; p => p.Age >= 18 est une fonction anonyme
IEnumerable<Personne> majeurs = personnes.Where(p => p.Age >= 18);
Affiche("[Where : les majeurs]", majeurs);

// Select : une projection, ici chaque personne devient une chaîne
IEnumerable<string> noms = personnes.Select(p => $"{p.Prenom} {p.Nom.ToUpper()}");
Affiche("[Select : prénom NOM]", noms);

// les opérateurs s'enchaînent : les noms des Angevins, par ordre alphabétique
var angevins = personnes
    .Where(p => p.Ville == "Angers")
    .OrderBy(p => p.Nom)
    .Select(p => p.Nom);
Affiche("[Where + OrderBy + Select : les Angevins]", angevins);

// tri sur plusieurs critères : âge décroissant, puis nom croissant
var tries = personnes.OrderByDescending(p => p.Age).ThenBy(p => p.Nom);
Affiche("[OrderByDescending + ThenBy]", tries);

// la même requête que 'angevins' écrite avec la syntaxe de requête
var angevins2 = from p in personnes
                where p.Ville == "Angers"
                orderby p.Nom
                select p.Nom;
Affiche("[syntaxe de requête : les Angevins]", angevins2);

// Select peut aussi fournir la position de l'élément
var numerotes = personnes.Take(3).Select((p, i) => $"{i + 1}. {p.Nom}");
Affiche("[Select avec indice]", numerotes);

// affichage d'une séquence
static void Affiche<T>(string titre, IEnumerable<T> sequence)
{
    Console.WriteLine(titre);
    foreach (T element in sequence)
    {
        Console.WriteLine($"  {element}");
    }
}

// une personne
record Personne(string Nom, string Prenom, int Age, string Ville);

Résultats :

> dotnet run linq-02.cs
[Where : les majeurs]
  Personne { Nom = dupont, Prenom = jean, Age = 45, Ville = Angers }
  Personne { Nom = durand, Prenom = paul, Age = 32, Ville = Angers }
  Personne { Nom = leroy, Prenom = sophie, Age = 28, Ville = Nantes }
  Personne { Nom = simon, Prenom = julie, Age = 52, Ville = Angers }
  Personne { Nom = laurent, Prenom = marc, Age = 32, Ville = Lyon }
[Select : prénom NOM]
  jean DUPONT
  marie MARTIN
  paul DURAND
  sophie LEROY
  luc MOREAU
  julie SIMON
  marc LAURENT
[Where + OrderBy + Select : les Angevins]
  dupont
  durand
  simon
[OrderByDescending + ThenBy]
  Personne { Nom = simon, Prenom = julie, Age = 52, Ville = Angers }
  Personne { Nom = dupont, Prenom = jean, Age = 45, Ville = Angers }
  Personne { Nom = durand, Prenom = paul, Age = 32, Ville = Angers }
  Personne { Nom = laurent, Prenom = marc, Age = 32, Ville = Lyon }
  Personne { Nom = leroy, Prenom = sophie, Age = 28, Ville = Nantes }
  Personne { Nom = martin, Prenom = marie, Age = 17, Ville = Paris }
  Personne { Nom = moreau, Prenom = luc, Age = 15, Ville = Paris }
[syntaxe de requête : les Angevins]
  dupont
  durand
  simon
[Select avec indice]
  1. dupont
  2. martin
  3. durand

Commentaires :

  • lignes 4-13 : la liste des personnes, initialisée par une expression de collection ; [new(...)] crée un objet du type attendu, ici [Personne] ;
  • ligne 56 : l'enregistrement [Personne]. Une application fichier unique place ses déclarations de types après les instructions de haut niveau ;
  • ligne 16 : la méthode [Where] filtre la séquence : elle ne garde que les éléments qui vérifient une condition. Cette condition lui est passée sous la forme d'une fonction anonyme [p => p.Age >= 18], qui se lit « la fonction qui, à une personne p, associe le booléen p.Age >= 18 ». On appelle cette écriture une expression lambda. [Where] appelle cette fonction pour chaque personne. Les lambdas seront étudiées en détail au chapitre 7 (« Délégués, lambdas et événements ») ; il nous suffit ici de savoir les lire ;
  • le résultat de [Where] est une nouvelle séquence [IEnumerable<Personne>] : la liste [personnes] n'est pas modifiée. C'est vrai de toutes les méthodes de LINQ ;
  • la méthode [Affiche] affiche chaque personne avec la méthode [ToString] générée pour l'enregistrement : [Personne { Nom = dupont, ... }] ;
  • ligne 20 : la méthode [Select] projette chaque élément de la séquence sur une nouvelle valeur, ici une chaîne. Le résultat est une séquence [IEnumerable<string>] ;
  • lignes 24-27 : les méthodes de LINQ rendent des séquences, auxquelles on peut appliquer d'autres méthodes de LINQ. On écrit souvent une méthode par ligne : cette chaîne d'appels se lit de haut en bas comme une recette ;
  • ligne 26 : [OrderBy] trie la séquence selon la clé rendue par la lambda, ici le nom ;
  • ligne 31 : [OrderByDescending] trie par ordre décroissant ; [ThenBy] (ou [ThenByDescending]) ajoute un critère secondaire, utilisé pour départager les éléments égaux selon le premier critère : durand et laurent, qui ont tous deux 32 ans, sont classés par nom (lignes 23-24) ;
  • lignes 35-38 : la même requête que [angevins] écrite avec la syntaxe de requête, proche de SQL. Le compilateur la traduit exactement en la chaîne d'appels [Where(...).OrderBy(...).Select(...)] : les deux écritures sont équivalentes. La syntaxe de requête est plus lisible pour les jointures et les regroupements ; la syntaxe de méthode est plus courante, et elle seule donne accès à toutes les méthodes de LINQ ([Take], [Count], [First]…) ;
  • ligne 42 : une variante de [Select] passe à la lambda l'élément et sa position dans la séquence.

5.5.3. Quantificateurs, agrégats, choix d'éléments

Le programme [linq-03.cs] présente des méthodes qui rendent une seule valeur, et des méthodes qui sélectionnent une partie de la séquence :

// LINQ : quantificateurs, agrégats, choix d'éléments, partitions

List<Personne> personnes =
[
    new("dupont", "jean", 45, "Angers"),
    new("martin", "marie", 17, "Paris"),
    new("durand", "paul", 32, "Angers"),
    new("leroy", "sophie", 28, "Nantes"),
    new("moreau", "luc", 15, "Paris"),
    new("simon", "julie", 52, "Angers"),
    new("laurent", "marc", 32, "Lyon"),
];

// quantificateurs : Any (au moins un), All (tous)
Console.WriteLine($"Any(mineur)={personnes.Any(p => p.Age < 18)}, All(mineur)={personnes.All(p => p.Age < 18)}, Any()={personnes.Any()}");

// agrégats
Console.WriteLine($"Count()={personnes.Count()}, Count(Angers)={personnes.Count(p => p.Ville == "Angers")}");
Console.WriteLine($"Sum(âge)={personnes.Sum(p => p.Age)}, Average(âge)={personnes.Average(p => p.Age):F2}");
Console.WriteLine($"Min(âge)={personnes.Min(p => p.Age)}, Max(âge)={personnes.Max(p => p.Age)}");
// MinBy, MaxBy rendent l'élément lui-même et non la valeur minimale ou maximale
Console.WriteLine($"MinBy(âge)={personnes.MinBy(p => p.Age)?.Nom}, MaxBy(âge)={personnes.MaxBy(p => p.Age)?.Nom}");

// choix d'un élément
Console.WriteLine($"First()={personnes.First().Nom}, First(Paris)={personnes.First(p => p.Ville == "Paris").Nom}, Last()={personnes.Last().Nom}");
Personne? inconnu = personnes.FirstOrDefault(p => p.Ville == "Brest");
Console.WriteLine($"FirstOrDefault(Brest)={inconnu?.Nom ?? "null"}");
Console.WriteLine($"Single(Lyon)={personnes.Single(p => p.Ville == "Lyon").Nom}");
try
{
    // Single exige qu'il y ait exactement un élément
    _ = personnes.Single(p => p.Ville == "Angers");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"Single(Angers) : {e.GetType().Name} : {e.Message}");
}
try
{
    // First exige qu'il y ait au moins un élément
    _ = personnes.First(p => p.Ville == "Brest");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"First(Brest) : {e.GetType().Name} : {e.Message}");
}

// Distinct : élimine les doublons
var villes = personnes.Select(p => p.Ville).Distinct();
Console.WriteLine($"Distinct(villes)=[{string.Join(", ", villes)}]");
// DistinctBy : un élément par valeur de la clé
var unParVille = personnes.DistinctBy(p => p.Ville).Select(p => p.Nom);
Console.WriteLine($"DistinctBy(ville)=[{string.Join(", ", unParVille)}]");

// partitions : Take, Skip, TakeWhile
var noms = personnes.Select(p => p.Nom);
Console.WriteLine($"Take(2)=[{string.Join(", ", noms.Take(2))}], Skip(5)=[{string.Join(", ", noms.Skip(5))}]");
Console.WriteLine($"Skip(2).Take(2)=[{string.Join(", ", noms.Skip(2).Take(2))}], TakeWhile(longueur 6)=[{string.Join(", ", noms.TakeWhile(n => n.Length == 6))}]");

// Chunk : découpage en paquets de taille fixe (pagination)
int page = 1;
foreach (Personne[] paquet in personnes.Chunk(3))
{
    Console.WriteLine($"page {page++} : {string.Join(", ", paquet.Select(p => p.Nom))}");
}

record Personne(string Nom, string Prenom, int Age, string Ville);

Résultats :

> dotnet run linq-03.cs
Any(mineur)=True, All(mineur)=False, Any()=True
Count()=7, Count(Angers)=3
Sum(âge)=221, Average(âge)=31,57
Min(âge)=15, Max(âge)=52
MinBy(âge)=moreau, MaxBy(âge)=simon
First()=dupont, First(Paris)=martin, Last()=laurent
FirstOrDefault(Brest)=null
Single(Lyon)=laurent
Single(Angers) : InvalidOperationException : Sequence contains more than one matching element
First(Brest) : InvalidOperationException : Sequence contains no matching element
Distinct(villes)=[Angers, Paris, Nantes, Lyon]
DistinctBy(ville)=[dupont, martin, leroy, laurent]
Take(2)=[dupont, martin], Skip(5)=[simon, laurent]
Skip(2).Take(2)=[durand, leroy], TakeWhile(longueur 6)=[dupont, martin, durand]
page 1 : dupont, martin, durand
page 2 : leroy, moreau, simon
page 3 : laurent

Commentaires :

  • ligne 15 : [Any] rend [true] si au moins un élément vérifie la condition, [All] si tous la vérifient. Sans paramètre, [Any] indique si la séquence est non vide ;
  • lignes 18-20 : les agrégats [Count], [Sum], [Average], [Min], [Max] calculent une valeur à partir de toute la séquence. [Count] accepte une condition (une liste a aussi une propriété [Count], qu'on préférera à la méthode quand il n'y a pas de condition) ; [Sum], [Average], [Min], [Max] acceptent une lambda qui indique la valeur à prendre en compte pour chaque élément ;
  • ligne 22 : [MinBy] et [MaxBy] (.NET 6) rendent l'élément (ici la personne) pour lequel la clé est minimale ou maximale, et non la valeur de la clé. Pour une séquence vide d'objets (type référence), elles rendent [null], d'où l'opérateur [?.] ;
  • ligne 25 : [First] rend le premier élément de la séquence, ou le premier qui vérifie une condition ; [Last] le dernier ;
  • ligne 26 : si aucun élément ne convient, [First] lance une exception (ligne 11 des résultats) alors que [FirstOrDefault] rend la valeur par défaut du type, ici [null] ;
  • ligne 28 : [Single] rend l'unique élément vérifiant la condition, et lance une exception s'il y en a zéro ou plusieurs (ligne 10). On l'utilise quand l'unicité est une règle des données, par exemple la recherche d'un élément par sa clé. [SingleOrDefault] rend la valeur par défaut s'il n'y a aucun élément ;
  • ligne 32 : le résultat de l'appel n'est pas utilisé : on l'affecte à l'élément ignoré [_] (C# 7) pour le signaler ;
  • ligne 49 : [Distinct] élimine les doublons ; [DistinctBy] (.NET 6) garde le premier élément de chaque valeur de la clé ;
  • lignes 57-58 : [Take(n)] garde les n premiers éléments, [Skip(n)] les saute. Combinées, elles extraient une « page » de résultats. [TakeWhile] garde les éléments tant qu'une condition est vérifiée et s'arrête au premier élément qui ne la vérifie pas ;
  • ligne 62 : [Chunk] (.NET 6) découpe la séquence en tableaux de 3 éléments, le dernier pouvant être plus court ;
  • ligne 64 : la variable [page] est incrémentée après avoir été utilisée dans l'interpolation.

5.5.4. Types anonymes, regroupements, jointures

Le programme [linq-04.cs] présente les types anonymes et des opérations plus élaborées :

// LINQ : types anonymes, regroupements, jointures, conversions

List<Personne> personnes =
[
    new("dupont", "jean", 45, "Angers"),
    new("martin", "marie", 17, "Paris"),
    new("durand", "paul", 32, "Angers"),
    new("leroy", "sophie", 28, "Nantes"),
    new("moreau", "luc", 15, "Paris"),
    new("simon", "julie", 52, "Angers"),
    new("laurent", "marc", 32, "Lyon"),
];
List<Ville> villes =
[
    new("Angers", "Maine-et-Loire"),
    new("Nantes", "Loire-Atlantique"),
    new("Paris", "Paris"),
    new("Brest", "Finistère"),
];

// 1 - un type anonyme : un objet dont le type, sans nom, est créé par le compilateur
var p1 = new { Nom = "dupont", Age = 45 };
Console.WriteLine($"[type anonyme] {p1}, p1.Nom={p1.Nom}");
// Select vers un type anonyme : on ne garde que les propriétés utiles
var resumes = personnes.Where(p => p.Age > 40).Select(p => new { p.Nom, Decennie = p.Age / 10 * 10 });
foreach (var r in resumes)
{
    Console.WriteLine($"  {r.Nom} a plus de {r.Decennie} ans");
}

// 2 - GroupBy : regroupement des personnes par ville
Console.WriteLine("[GroupBy]");
foreach (IGrouping<string, Personne> groupe in personnes.GroupBy(p => p.Ville))
{
    // un groupe a une clé (Key) et est lui-même une séquence de personnes
    Console.WriteLine($"  {groupe.Key} : {groupe.Count()} personne(s), âge moyen {groupe.Average(p => p.Age):F1} ({string.Join(", ", groupe.Select(p => p.Nom))})");
}

// 3 - Join : association des personnes et des villes sur le nom de la ville
Console.WriteLine("[Join]");
var jointure = personnes.Join(villes,
    p => p.Ville,                                    // clé côté personnes
    v => v.Nom,                                      // clé côté villes
    (p, v) => new { p.Nom, v.Departement });         // ce qu'on garde d'un couple
foreach (var j in jointure)
{
    Console.WriteLine($"  {j.Nom} habite le département {j.Departement}");
}
// la même jointure en syntaxe de requête : Lyon, absente de la liste des villes, disparaît aussi
var jointure2 = from p in personnes
                join v in villes on p.Ville equals v.Nom
                where p.Age >= 18
                select $"{p.Nom}/{v.Departement}";
Console.WriteLine($"[join en syntaxe de requête] {string.Join(", ", jointure2)}");

// 4 - conversions : ToList, ToArray, ToDictionary
List<string> nomsMajeurs = personnes.Where(p => p.Age >= 18).Select(p => p.Nom).ToList();
Dictionary<string, int> agesParNom = personnes.ToDictionary(p => p.Nom, p => p.Age);
Console.WriteLine($"[ToList] {nomsMajeurs.Count} majeurs ; [ToDictionary] agesParNom[\"leroy\"]={agesParNom["leroy"]}");

// 5 - .NET 9 : CountBy, AggregateBy, Index
Console.WriteLine("[CountBy]");
foreach (KeyValuePair<string, int> paire in personnes.CountBy(p => p.Ville))
{
    Console.WriteLine($"  {paire.Key}={paire.Value}");
}
Console.WriteLine("[AggregateBy : somme des âges par ville]");
foreach (var (ville, total) in personnes.AggregateBy(p => p.Ville, 0, (somme, p) => somme + p.Age))
{
    Console.WriteLine($"  {ville}={total}");
}
Console.WriteLine("[Index]");
foreach (var (index, personne) in personnes.Where(p => p.Age < 30).Index())
{
    Console.WriteLine($"  {index} : {personne.Nom}");
}

record Personne(string Nom, string Prenom, int Age, string Ville);
record Ville(string Nom, string Departement);

Résultats :

> dotnet run linq-04.cs
[type anonyme] { Nom = dupont, Age = 45 }, p1.Nom=dupont
  dupont a plus de 40 ans
  simon a plus de 50 ans
[GroupBy]
  Angers : 3 personne(s), âge moyen 43,0 (dupont, durand, simon)
  Paris : 2 personne(s), âge moyen 16,0 (martin, moreau)
  Nantes : 1 personne(s), âge moyen 28,0 (leroy)
  Lyon : 1 personne(s), âge moyen 32,0 (laurent)
[Join]
  dupont habite le département Maine-et-Loire
  martin habite le département Paris
  durand habite le département Maine-et-Loire
  leroy habite le département Loire-Atlantique
  moreau habite le département Paris
  simon habite le département Maine-et-Loire
[join en syntaxe de requête] dupont/Maine-et-Loire, durand/Maine-et-Loire, leroy/Loire-Atlantique, simon/Maine-et-Loire
[ToList] 5 majeurs ; [ToDictionary] agesParNom["leroy"]=28
[CountBy]
  Angers=3
  Paris=2
  Nantes=1
  Lyon=1
[AggregateBy : somme des âges par ville]
  Angers=129
  Paris=32
  Nantes=28
  Lyon=32
[Index]
  0 : martin
  1 : leroy
  2 : moreau

Commentaires :

  • ligne 22 : un type anonyme (C# 3) : l'expression [new { Nom = ..., Age = ... }] crée un objet dont la classe, sans nom, est générée par le compilateur avec deux propriétés en lecture seule [Nom] et [Age]. Comme ce type n'a pas de nom, la variable doit être déclarée avec [var]. Le compilateur connaît néanmoins parfaitement ce type : [p1.Nom] est vérifié à la compilation, et l'éditeur propose les propriétés de [p1] en autocomplétion ;
  • ligne 25 : l'usage principal des types anonymes : dans un [Select], ne garder que les données utiles, sans avoir à déclarer une classe pour cela. [p.Nom] est une abréviation de [Nom = p.Nom] ;
  • ligne 33 : [GroupBy] regroupe les éléments ayant la même clé. Le résultat est une séquence de groupes [IGrouping<TKey,TElement>] : chaque groupe a une propriété [Key], la valeur commune de la clé, et il est lui-même la séquence des éléments du groupe, à laquelle on peut appliquer [Count], [Average], [Select]… (ligne 36) ;
  • lignes 41-44 : [Join] associe chaque personne à la ville de même nom. Ses paramètres sont la seconde séquence, la clé de jointure de chaque côté, et une lambda qui construit le résultat à partir d'un couple (personne, ville). Comme une jointure SQL, [Join] ne garde que les couples qui se correspondent : laurent, qui habite Lyon, absente de la liste des villes, n'apparaît pas, ni Brest, qui n'a pas d'habitant ;
  • lignes 50-53 : la jointure en syntaxe de requête, plus lisible : [join ... in ... on ... equals ...]. On y ajoute un filtre [where]. La syntaxe de requête a aussi une clause [group ... by ...] pour les regroupements ;
  • ligne 57 : [ToList] (et [ToArray]) exécute la requête et range ses résultats dans une liste (un tableau) ;
  • ligne 58 : [ToDictionary] construit un dictionnaire : la première lambda donne la clé de chaque élément, la seconde sa valeur. Une clé en double provoque une exception ;
  • ligne 63 : [CountBy] (.NET 9) compte les éléments de chaque valeur de la clé. Il rend une séquence de couples [KeyValuePair<string,int>]. C'est un raccourci pour [GroupBy(p => p.Ville).Select(g => (g.Key, g.Count()))] ;
  • ligne 68 : [AggregateBy] (.NET 9) cumule une valeur pour chaque valeur de la clé : on part de 0 et, pour chaque personne, on ajoute son âge à la somme de sa ville. Les couples rendus sont déconstruits dans la boucle [foreach] ;
  • ligne 73 : [Index] (.NET 9) associe à chaque élément sa position dans la séquence, sous la forme d'un tuple (position, élément).

5.5.5. L'exécution différée

Une requête LINQ n'est pas exécutée au moment où on l'écrit, mais au moment où on parcourt son résultat : c'est l'exécution différée. Le programme [linq-05.cs] le démontre avec des lambdas qui affichent ce qu'elles font :

// LINQ : l'exécution différée

List<int> nombres = [1, 2, 3, 4, 5, 6];

// 1 - la définition d'une requête ne l'exécute pas
Console.WriteLine("[définition de la requête]");
IEnumerable<int> carresPairs = nombres
    .Where(n =>
    {
        Console.WriteLine($"  Where examine {n}");
        return n % 2 == 0;
    })
    .Select(n =>
    {
        Console.WriteLine($"  Select calcule {n}*{n}");
        return n * n;
    });
Console.WriteLine("[la requête est définie, rien n'a encore été exécuté]");

// 2 - elle s'exécute au moment où on la parcourt, élément par élément
Console.WriteLine("[foreach]");
foreach (int c in carresPairs)
{
    Console.WriteLine($"  foreach reçoit {c}");
}

// 3 - la requête est réévaluée à chaque parcours : elle voit les modifications de la source
IEnumerable<int> grands = nombres.Where(n => n > 4);
Console.WriteLine($"[avant ajout] grands=[{string.Join(", ", grands)}]");
nombres.Add(10);
Console.WriteLine($"[après ajout de 10] grands=[{string.Join(", ", grands)}]");

// 4 - ToList exécute la requête immédiatement et mémorise le résultat
List<int> instantane = nombres.Where(n => n > 4).ToList();
nombres.Add(20);
Console.WriteLine($"[après ajout de 20] grands=[{string.Join(", ", grands)}], instantane=[{string.Join(", ", instantane)}]");

// 5 - les agrégats (Count, Sum, First...) s'exécutent eux aussi immédiatement
int combien = nombres.Count(n => n > 4);
nombres.Add(30);
Console.WriteLine($"[après ajout de 30] combien={combien}, nouveau Count={nombres.Count(n => n > 4)}");

// 6 - First arrête le parcours dès qu'il a trouvé un élément
Console.WriteLine("[First sur une requête]");
int premier = nombres.Where(n =>
{
    Console.WriteLine($"  Where examine {n}");
    return n > 2;
}).First();
Console.WriteLine($"  premier={premier}");

Résultats :

> dotnet run linq-05.cs
[définition de la requête]
[la requête est définie, rien n'a encore été exécuté]
[foreach]
  Where examine 1
  Where examine 2
  Select calcule 2*2
  foreach reçoit 4
  Where examine 3
  Where examine 4
  Select calcule 4*4
  foreach reçoit 16
  Where examine 5
  Where examine 6
  Select calcule 6*6
  foreach reçoit 36
[avant ajout] grands=[5, 6]
[après ajout de 10] grands=[5, 6, 10]
[après ajout de 20] grands=[5, 6, 10, 20], instantane=[5, 6, 10]
[après ajout de 30] combien=4, nouveau Count=5
[First sur une requête]
  Where examine 1
  Where examine 2
  Where examine 3
  premier=3

Commentaires :

  • lignes 7-16 : une requête qui garde les nombres pairs puis calcule leurs carrés. Les lambdas sont ici écrites avec un corps entre accolades, qui affiche un message avant de rendre sa valeur avec [return] ;
  • ligne 3 des résultats : après la définition de la requête, rien n'a été affiché : ni [Where] ni [Select] n'ont encore examiné un seul élément. [carresPairs] ne contient pas de résultats, mais la description d'un calcul ;
  • lignes 4-16 : la requête s'exécute pendant la boucle [foreach], élément par élément : chaque nombre traverse [Where], puis [Select] s'il est pair, puis arrive dans la boucle avant que le nombre suivant ne soit examiné. Ce sont des itérateurs, comme [Pairs] dans [linq-01.cs] ;
  • lignes 28-31 : la requête [grands] est réévaluée à chaque parcours : elle tient compte du nombre 10 ajouté à la liste après sa définition ;
  • ligne 34 : [ToList] exécute la requête immédiatement et mémorise ses résultats : [instantane] est une liste, qui ne voit pas l'ajout de 20 (ligne 19) ;
  • ligne 39 : les méthodes qui rendent une valeur unique ([Count], [Sum], [First], [Any]…) parcourent elles aussi la séquence immédiatement ;
  • lignes 45-49 : [First] arrête le parcours dès qu'il a obtenu un élément : les nombres qui suivent 3 ne sont jamais examinés.

L'exécution différée a deux conséquences pratiques :

  • une requête parcourue plusieurs fois est exécutée plusieurs fois. Si son calcul est coûteux, ou si l'on veut un résultat figé, on la convertit une fois pour toutes avec [ToList] ou [ToArray] ;
  • les requêtes se composent sans coût : enchaîner [Where], [Select] et [Take] ne crée aucune collection intermédiaire, et seuls les éléments nécessaires sont calculés.

Nous retrouverons LINQ tout au long du cours, et nous verrons au chapitre 7 comment ses méthodes sont construites.

5.6. Les fichiers texte

Les classes de lecture et d'écriture des fichiers se trouvent dans l'espace de noms [System.IO], qui fait partie des [using] implicites.

5.6.1. La classe StreamReader

La classe [System.IO.StreamReader] permet de lire le contenu d'un fichier texte. Elle est en fait capable d'exploiter des flux qui ne sont pas des fichiers (une connexion réseau, par exemple). Voici quelques-unes de ses propriétés et méthodes.

Constructeurs :

constructeur
rôle
[public StreamReader(string path)]
construit un flux de lecture à partir du fichier de chemin [path]. Le contenu du fichier peut être encodé de diverses façons ; par défaut, c'est l'encodage UTF-8 qui est utilisé. Une autre surcharge permet de préciser l'encodage : [new StreamReader(path, Encoding.Latin1)]

Propriétés :

propriété
rôle
[public bool EndOfStream { get; }]
[true] si le flux a été lu entièrement

Méthodes :

méthode
rôle
[public void Dispose()]
ferme le flux et libère les ressources allouées pour sa gestion. À faire obligatoirement après exploitation du flux, ce dont se charge l'instruction [using]. [Close] fait la même chose
[public int Peek()]
rend le caractère suivant du flux sans le consommer ; -1 à la fin du flux
[public int Read()]
rend le caractère suivant du flux et avance d'un caractère ; -1 à la fin du flux
[public string? ReadLine()]
rend la ligne suivante du flux, sans la marque de fin de ligne, ou [null] si on était à la fin du flux
[public string ReadToEnd()]
rend la fin du flux, ou "" si on était à la fin du flux

Le programme [fichiers-01.cs] est le suivant :

// lecture d'un fichier texte avec StreamReader

// le dossier courant : c'est là que sont cherchés les fichiers désignés par un nom relatif
Console.WriteLine($"Dossier courant : {Path.GetFileName(Environment.CurrentDirectory)}");
// le nom du fichier peut être passé en argument ; sinon infos.txt
string nomFichier = args.Length > 0 ? args[0] : "infos.txt";
try
{
    // lecture 1 : ligne par ligne
    Console.WriteLine("Lecture 1----------------");
    using (var flux = new StreamReader(nomFichier))
    {
        string? ligne;
        // ReadLine rend null à la fin du fichier
        while ((ligne = flux.ReadLine()) != null)
        {
            Console.WriteLine(ligne);
        }
    } // ici le flux est fermé

    // lecture 2 : tout le fichier d'un coup, avec une déclaration using
    Console.WriteLine("Lecture 2----------------");
    using var flux2 = new StreamReader(nomFichier);
    Console.Write(flux2.ReadToEnd());
    // flux2 sera fermé à la fin du bloc qui contient sa déclaration, ici le bloc try
}
catch (FileNotFoundException e)
{
    // e.FileName est le chemin complet du fichier ; on n'en affiche que le nom
    Console.WriteLine($"{e.GetType().Name} : le fichier [{Path.GetFileName(e.FileName)}] n'existe pas");
}
catch (IOException e)
{
    // les autres erreurs d'entrée-sortie
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}

Commentaires :

  • ligne 4 : affiche le nom du dossier courant, celui à partir duquel on a lancé la commande [dotnet run]. On n'affiche que le dernier élément du chemin, avec [Path.GetFileName], pour ne pas encombrer les résultats d'un chemin complet ;
  • ligne 6 : le fichier lu est [infos.txt] ou celui passé en argument. Comme c'est un nom relatif, il est cherché dans le dossier courant ;
  • ligne 11 : la structure [using (flux = ...) { ... }] garantit la fermeture du flux à la sortie du bloc, même si une exception se produit dans le bloc. Elle évite d'avoir à appeler explicitement la méthode de fermeture. Elle s'applique à tout objet implémentant l'interface [IDisposable], c'est-à-dire possédant une méthode [Dispose] qui libère des ressources (fichier, connexion réseau, connexion à une base de données…) ;
  • lignes 15-17 : le fichier est lu ligne par ligne. L'affectation [ligne = flux.ReadLine()] est une expression dont la valeur est celle de [ligne] : on la compare à [null], qui signale la fin du fichier. La variable [ligne] est de type [string?] : [ReadLine] peut rendre [null] ;
  • ligne 23 : une déclaration using (C# 8). Il n'y a plus de bloc : la variable [flux2] sera libérée à la fin du bloc qui contient sa déclaration, ici le bloc [try]. Cette écriture, plus légère, est la plus courante aujourd'hui ;
  • ligne 24 : le fichier est lu d'un seul coup ;
  • lignes 27-32 : les erreurs d'entrée-sortie sont signalées par des exceptions dérivées de [IOException]. [FileNotFoundException] en est une : elle est donc interceptée avant. Sa propriété [FileName] contient le chemin complet du fichier cherché.

Le fichier [infos.txt] est le suivant :

1
2
3
12620:0:0
13190:0,05:631
15640:0,1:1290,5

Il est placé dans le dossier [05-classes-dotnet], d'où l'on exécute le programme :

> dotnet run fichiers-01.cs
Dossier courant : 05-classes-dotnet
Lecture 1----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5
Lecture 2----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5

Avec un fichier inexistant :

1
2
3
4
> dotnet run fichiers-01.cs -- xx.txt
Dossier courant : 05-classes-dotnet
Lecture 1----------------
FileNotFoundException : le fichier [xx.txt] n'existe pas

Le choix du dossier courant pour trouver les fichiers de données est naturel pour une application fichier unique, que l'on exécute depuis son dossier. Pour un projet, l'exécutable se trouve dans un sous-dossier [bin/Debug/net10.0] du projet : nous verrons avec l'application exemple comment y faire copier les fichiers de données et comment les y trouver.

5.6.2. La classe StreamWriter

La classe [System.IO.StreamWriter] permet d'écrire dans un fichier texte. Comme la classe [StreamReader], elle est en fait capable d'exploiter des flux qui ne sont pas des fichiers. Voici quelques-unes de ses propriétés et méthodes.

Constructeurs :

constructeur
rôle
[public StreamWriter(string path)]
construit un flux d'écriture dans le fichier de chemin [path]. Le fichier est créé, ou écrasé s'il existe. Par défaut, le texte est encodé en UTF-8, sans BOM (voir plus loin)
[public StreamWriter(string path, bool append)]
si [append] vaut [true], les écritures sont ajoutées à la fin du fichier existant

Propriétés :

propriété
rôle
[public bool AutoFlush { get; set; }]
fixe le mode d'écriture dans le fichier. Si [AutoFlush] vaut [false] (valeur par défaut), l'écriture dans le flux n'est pas immédiate : le texte est d'abord écrit dans une mémoire tampon, puis dans le fichier lorsque le tampon est plein, lorsqu'on le vide explicitement par la méthode [Flush] ou lorsqu'on ferme le flux. C'est le mode le plus efficace pour les fichiers, parce qu'il limite les accès au disque. Il ne convient pas à tous les flux, notamment aux flux réseau : dans un dialogue entre deux partenaires, ce qui est écrit par l'un doit être immédiatement lu par l'autre, et le flux d'écriture doit être en mode [AutoFlush=true]
[public string NewLine { get; set; }]
la marque de fin de ligne écrite par [WriteLine]. Par défaut, celle du système : "\r\n" sous Windows, "\n" sous Linux et macOS
[public Encoding Encoding { get; }]
l'encodage utilisé

Méthodes :

méthode
rôle
[public void Dispose()]
ferme le flux, après avoir écrit dans le fichier le contenu du tampon. À faire obligatoirement après exploitation du flux ; [Close] fait la même chose
[public void Flush()]
écrit dans le fichier le contenu du tampon, sans attendre qu'il soit plein
[public void Write(T value)]
écrit [value] dans le fichier associé au flux. Ici [T] n'est pas un type générique mais symbolise le fait que la méthode [Write] accepte différents types de paramètres ([string], [int], [object]…)
[public void WriteLine(T value)]
même chose que [Write] mais avec la marque de fin de ligne ([NewLine]) en plus

Le programme [fichiers-02.cs] est le suivant :

// écriture d'un fichier texte avec StreamWriter
const string nomFichier = "infos2.txt";
try
{
    // création du fichier texte (s'il existe déjà, il est écrasé)
    using (var flux = new StreamWriter(nomFichier))
    {
        Console.WriteLine($"Mode AutoFlush : {flux.AutoFlush}, encodage : {flux.Encoding.WebName}");
        // boucle tant que la ligne saisie est non vide
        while (true)
        {
            Console.Write("ligne (rien pour arrêter) : ");
            string ligne = (Console.ReadLine() ?? "").Trim();
            if (ligne == "")
            {
                break;
            }
            // écriture de la ligne dans le fichier texte
            flux.WriteLine(ligne);
        }
    } // fermeture du flux : le tampon est écrit dans le fichier

    // vérification : on relit le fichier créé
    Console.WriteLine($"Contenu de {nomFichier} ({new FileInfo(nomFichier).Length} octets) :");
    using var lecteur = new StreamReader(nomFichier);
    Console.Write(lecteur.ReadToEnd());
}
catch (IOException e)
{
    Console.WriteLine($"L'erreur suivante s'est produite : {e.Message}");
}

Résultats :

1
2
3
4
5
6
7
8
> dotnet run fichiers-02.cs
Mode AutoFlush : False, encodage : utf-8
ligne (rien pour arrêter) : 1ère ligne
ligne (rien pour arrêter) : 2ième ligne
ligne (rien pour arrêter) : 
Contenu de infos2.txt (25 octets) :
1ère ligne
2ième ligne

Commentaires :

  • ligne 6 : de nouveau, nous utilisons l'instruction [using] afin de ne pas avoir à fermer explicitement le flux. Cette fermeture est faite automatiquement à la sortie du bloc (accolade fermante de la ligne 21). C'est elle qui écrit dans le fichier le texte resté dans le tampon : sans elle, le fichier pourrait rester incomplet ;
  • ligne 19 : écriture d'une ligne dans le fichier texte ;
  • ligne 24 : la classe [FileInfo] donne des informations sur un fichier, ici sa taille en octets. Les 25 octets se décomposent ainsi : 21 caractères, plus 1 octet supplémentaire pour chacun des deux [è] (qui occupent 2 octets en UTF-8), plus deux fins de ligne d'un octet ("\n" sous Linux, où ont été faites les exécutions ; sous Windows, chaque fin de ligne "\r\n" occuperait 2 octets) ;
  • ligne 28 : pourquoi un [try / catch] ? Nous pourrions donner un nom de fichier de la forme [rep1/rep2/fichier] avec un dossier [rep1/rep2] qui n'existe pas, rendant impossible la création du fichier. Une exception [DirectoryNotFoundException], dérivée de [IOException], serait alors lancée. Il existe d'autres cas d'exception possibles (disque plein, droits insuffisants...).

5.6.3. Les classes File, Path et Directory

Pour les opérations courantes, la classe statique [File] évite de manipuler des flux : une seule méthode ouvre le fichier, le lit ou l'écrit entièrement, puis le ferme. Les classes statiques [Path] et [Directory] manipulent respectivement les chemins et les dossiers.

méthode
rôle
[File.ReadAllText(path)]
rend le contenu du fichier sous la forme d'une chaîne
[File.ReadAllLines(path)]
rend les lignes du fichier dans un tableau [string[]]
[File.ReadLines(path)]
rend les lignes du fichier sous la forme d'une séquence [IEnumerable<string>] lue à la demande
[File.WriteAllText(path, contents)]
crée (ou écrase) le fichier et y écrit [contents] ; [WriteAllLines] écrit une collection de lignes
[File.AppendAllText(path, contents)], [File.AppendAllLines(path, lines)]
ajoute du texte, des lignes, à la fin du fichier (créé s'il n'existe pas)
[File.ReadAllBytes(path)], [File.WriteAllBytes(path, bytes)]
lit, écrit le fichier sous la forme d'un tableau d'octets
[File.Exists(path)], [File.Delete(path)], [File.Copy(source, dest)], [File.Move(source, dest)]
teste l'existence d'un fichier, le supprime, le copie, le déplace
[Path.Combine(p1, p2, ...)]
construit un chemin à partir de ses éléments, avec le séparateur du système
[Path.GetFileName(path)], [Path.GetExtension(path)], [Path.GetDirectoryName(path)]
extrait d'un chemin le nom du fichier, son extension, son dossier
[Directory.CreateDirectory(path)]
crée le dossier et ses dossiers parents s'ils n'existent pas
[Directory.GetFiles(path, pattern)], [Directory.EnumerateFiles(path, pattern)]
les fichiers du dossier dont le nom correspond au modèle ([*.txt]…), dans un tableau ou une séquence
[Directory.Exists(path)], [Directory.Delete(path, recursive)]
teste l'existence d'un dossier, le supprime (avec son contenu si [recursive] vaut [true])

Le programme [fichiers-03.cs] les utilise :

using System.Text;

// les classes statiques File, Path et Directory

// Path.Combine construit un chemin avec le séparateur du système (\ sous Windows, / sous Linux)
string dossier = "donnees";
string chemin = Path.Combine(dossier, "villes.txt");
Console.WriteLine($"chemin={chemin}, nom={Path.GetFileName(chemin)}, sans extension={Path.GetFileNameWithoutExtension(chemin)}, extension={Path.GetExtension(chemin)}");

// Directory.CreateDirectory crée le dossier (et ses parents) s'il n'existe pas
Directory.CreateDirectory(dossier);

// File.WriteAllText : crée (ou écrase) un fichier avec le texte donné, en UTF-8 sans BOM
File.WriteAllText(chemin, "Angers\nNantes\n");
// File.AppendAllLines : ajoute des lignes à la fin du fichier
File.AppendAllLines(chemin, ["Paris", "Lyon"]);
// File.ReadAllLines : toutes les lignes dans un tableau
string[] lignes = File.ReadAllLines(chemin);
Console.WriteLine($"ReadAllLines : {lignes.Length} lignes [{string.Join(", ", lignes)}]");
// File.ReadAllText : tout le fichier dans une chaîne
Console.WriteLine($"ReadAllText : {File.ReadAllText(chemin).Length} caractères");
// File.ReadLines : les lignes sont lues une à une, à la demande (IEnumerable<string>)
foreach (string ville in File.ReadLines(chemin).Where(l => l.StartsWith('N') || l.StartsWith('L')))
{
    Console.WriteLine($"ReadLines + Where : {ville}");
}

// l'encodage : par défaut UTF-8 sans BOM ; on peut en imposer un autre
string texte = "élève";
File.WriteAllText(Path.Combine(dossier, "utf8.txt"), texte);
File.WriteAllText(Path.Combine(dossier, "utf8bom.txt"), texte, new UTF8Encoding(true));
File.WriteAllText(Path.Combine(dossier, "latin1.txt"), texte, Encoding.Latin1);
foreach (string nom in new[] { "utf8.txt", "utf8bom.txt", "latin1.txt" })
{
    byte[] octets = File.ReadAllBytes(Path.Combine(dossier, nom));
    Console.WriteLine($"{nom,-12}: {octets.Length} octets : {Convert.ToHexString(octets)}");
}
// un fichier Latin-1 relu en UTF-8 : les caractères accentués sont perdus
Console.WriteLine($"latin1.txt lu en UTF-8 : {File.ReadAllText(Path.Combine(dossier, "latin1.txt"))}");
Console.WriteLine($"latin1.txt lu en Latin-1 : {File.ReadAllText(Path.Combine(dossier, "latin1.txt"), Encoding.Latin1)}");

// File.Exists, Directory.GetFiles
Console.WriteLine($"Exists(villes.txt)={File.Exists(chemin)}, Exists(xx.txt)={File.Exists(Path.Combine(dossier, "xx.txt"))}");
foreach (string fichier in Directory.GetFiles(dossier, "*.txt").Order())
{
    Console.WriteLine($"  {Path.GetFileName(fichier),-12} {new FileInfo(fichier).Length,3} octets");
}

// ménage : suppression du dossier et de son contenu
Directory.Delete(dossier, recursive: true);
Console.WriteLine($"après Delete : Directory.Exists={Directory.Exists(dossier)}");

Résultats :

> dotnet run fichiers-03.cs
chemin=donnees/villes.txt, nom=villes.txt, sans extension=villes, extension=.txt
ReadAllLines : 4 lignes [Angers, Nantes, Paris, Lyon]
ReadAllText : 25 caractères
ReadLines + Where : Nantes
ReadLines + Where : Lyon
utf8.txt    : 7 octets : C3A96CC3A87665
utf8bom.txt : 10 octets : EFBBBFC3A96CC3A87665
latin1.txt  : 5 octets : E96CE87665
latin1.txt lu en UTF-8 : �l�ve
latin1.txt lu en Latin-1 : élève
Exists(villes.txt)=True, Exists(xx.txt)=False
  latin1.txt     5 octets
  utf8.txt       7 octets
  utf8bom.txt   10 octets
  villes.txt    25 octets
après Delete : Directory.Exists=False

Commentaires :

  • ligne 7 : [Path.Combine] utilise le séparateur de chemins du système. Les exécutions du cours ont été faites sous Linux : le chemin obtenu est [donnees/villes.txt] ; sous Windows, il serait [donnees\villes.txt]. Construire les chemins avec [Path.Combine] plutôt qu'en concaténant des chaînes rend le code portable ;
  • ligne 11 : la création du dossier [donnees] ne provoque pas d'erreur s'il existe déjà ;
  • lignes 14-18 : écriture, ajout et lecture en une instruction chacune ;
  • ligne 23 : [File.ReadLines] rend une séquence : les lignes sont lues une à une, au fur et à mesure du parcours, comme les éléments d'un itérateur. On peut y appliquer LINQ, et traiter ainsi un fichier de plusieurs gigaoctets sans le charger en mémoire, ce que [ReadAllLines] ferait. Le fichier est fermé à la fin du parcours ;
  • lignes 29-36 : le même texte « élève » est écrit avec trois encodages, puis les octets des fichiers sont affichés en hexadécimal :
    • en UTF-8, encodage par défaut de .NET, les caractères accentués occupent 2 octets (é = C3 A9) et les autres 1 octet : 7 octets pour 5 caractères ;
    • en UTF-8 avec BOM (Byte Order Mark), le fichier commence par les 3 octets EF BB BF, qui signalent aux logiciels que le fichier est encodé en UTF-8. [new UTF8Encoding(true)] demande l'écriture de ce BOM ;
    • en Latin-1 (ISO-8859-1), encodage traditionnel des textes d'Europe occidentale, chaque caractère occupe un octet (é = E9) ;
  • ligne 39 : relire un fichier avec un autre encodage que celui de son écriture altère les caractères accentués : la suite d'octets E9 6C n'est pas valide en UTF-8, et chaque octet invalide est remplacé par le caractère �. À la lecture, [File.ReadAllText] et [StreamReader] reconnaissent le BOM s'il est présent ; sinon ils supposent l'UTF-8, sauf si on leur indique un autre encodage (ligne 40). Aujourd'hui, la règle est simple : on écrit et on lit les fichiers texte en UTF-8 ;
  • ligne 44 : les noms des fichiers texte du dossier. [GetFiles] ne garantit pas l'ordre des noms ; la méthode LINQ [Order] (.NET 7) les trie ;
  • ligne 50 : suppression du dossier et de son contenu. Le paramètre est passé par son nom, [recursive: true], ce qui rend l'appel plus lisible qu'un simple [true] (arguments nommés, chapitre 3).

Toutes ces méthodes existent en version asynchrone ([File.ReadAllTextAsync], [File.ReadAllLinesAsync]…) : nous les utiliserons au chapitre 9, consacré à la programmation asynchrone.

5.7. Les fichiers binaires

5.7.1. Les classes BinaryWriter et BinaryReader

Les classes [System.IO.BinaryWriter] et [System.IO.BinaryReader] servent à écrire et lire des fichiers binaires. Dans un tel fichier, les données ne sont pas écrites sous forme de texte mais sous leur forme interne : un [int] occupe toujours 4 octets, quelle que soit sa valeur.

Considérons l'application suivante :

1
2
3
4
// syntaxe : dotnet run fichiers-04.cs -- texte binaire logs
// on lit un fichier texte (texte) et on range son contenu dans un fichier binaire (binaire)
// le fichier texte a des lignes de la forme nom : âge qu'on écrit sous la forme (string, int)
// (logs) est un fichier texte de logs

Le fichier texte [personnes1.txt] a le contenu suivant :

1
2
3
4
5
6
7
8
9
paul : 10
helene : 15

jacques : 11
sylvain : 12
xx : -1

xx: yy : zz
xx : yy

Le programme [fichiers-04.cs] est le suivant :

// syntaxe : dotnet run fichiers-04.cs -- texte binaire logs
// on lit un fichier texte (texte) et on range son contenu dans un fichier binaire (binaire)
// le fichier texte a des lignes de la forme nom : âge qu'on écrit sous la forme (string, int)
// (logs) est un fichier texte de logs

// il faut 3 arguments
if (args.Length != 3)
{
    Console.WriteLine("syntaxe : dotnet run fichiers-04.cs -- texte binaire logs");
    return 1;
}
try
{
    // ouverture du fichier texte en lecture
    using var input = new StreamReader(args[0]);
    // ouverture du fichier binaire en écriture
    using var output = new BinaryWriter(new FileStream(args[1], FileMode.Create, FileAccess.Write));
    // ouverture du fichier des logs en écriture
    using var logs = new StreamWriter(args[2]);
    // exploitation du fichier texte
    string? ligne;
    int numLigne = 0, nbPersonnes = 0;
    while ((ligne = input.ReadLine()) != null)
    {
        // une ligne de plus
        numLigne++;
        // ligne vide ? on l'ignore
        if (ligne.Trim() == "")
        {
            continue;
        }
        // une ligne nom : âge
        string[] champs = ligne.Split(':', StringSplitOptions.TrimEntries);
        // il nous faut 2 champs
        if (champs.Length != 2)
        {
            logs.WriteLine($"La ligne n° [{numLigne}] du fichier [{args[0]}] a un nombre de champs incorrect");
            continue;
        }
        // le 1er champ doit être non vide
        bool erreur = false;
        string nom = champs[0];
        if (nom == "")
        {
            logs.WriteLine($"La ligne n° [{numLigne}] du fichier [{args[0]}] a un nom vide");
            erreur = true;
        }
        // le second champ doit être un entier >= 0
        if (!int.TryParse(champs[1], out int age) || age < 0)
        {
            logs.WriteLine($"La ligne n° [{numLigne}] du fichier [{args[0]}] a un âge [{champs[1]}] incorrect");
            erreur = true;
        }
        // si pas d'erreur, on écrit les données dans le fichier binaire
        if (!erreur)
        {
            output.Write(nom);
            output.Write(age);
            nbPersonnes++;
        }
    }
    Console.WriteLine($"{numLigne} lignes lues, {nbPersonnes} personnes écrites dans [{args[1]}], erreurs dans [{args[2]}]");
    // fin du bloc try : les trois flux sont fermés ici, dans l'ordre inverse de leur ouverture
}
catch (FileNotFoundException e)
{
    // e.FileName est le chemin complet du fichier ; on n'en affiche que le nom
    Console.WriteLine($"Le fichier [{Path.GetFileName(e.FileName)}] n'existe pas");
    return 2;
}
catch (IOException e)
{
    Console.WriteLine($"L'erreur suivante s'est produite : {e.GetType().Name} : {e.Message}");
    return 3;
}
return 0;

Attardons-nous sur les opérations concernant la classe [BinaryWriter] :

  • ligne 17 : l'objet [BinaryWriter] est ouvert par l'opération [new BinaryWriter(new FileStream(args[1], FileMode.Create, FileAccess.Write))]. L'argument du constructeur doit être un flux ([Stream]). Ici c'est un flux construit à partir d'un fichier ([FileStream]) dont on donne :
    • le nom ;
    • l'opération à faire, ici [FileMode.Create] pour créer le fichier (ou l'écraser s'il existe) ;
    • le type d'accès, ici [FileAccess.Write] pour un accès en écriture au fichier ;
  • lignes 57-58 : les opérations d'écriture. La classe [BinaryWriter] dispose de méthodes [Write] surchargées pour écrire les différents types de données simples ([int], [double], [decimal], [bool], [string]...) ;
  • lignes 15-19 : les trois flux sont ouverts par des déclarations [using]. Ils seront fermés à la fin du bloc [try], dans l'ordre inverse de leur ouverture. Le cours de 2008 les fermait dans une clause [finally] après avoir testé qu'ils n'étaient pas [null] : la déclaration [using] fait la même chose en une ligne ;
  • ligne 10 : les instructions de haut niveau peuvent se terminer par un [return n] : n est alors le code de sortie du programme, qu'un script peut tester (0 : succès) ;
  • ligne 33 : l'option [TrimEntries] supprime les espaces autour du nom et de l'âge ;
  • ligne 49 : l'âge doit être un entier positif ou nul. Grâce au court-circuit de l'opérateur [||], la condition [age < 0] n'est évaluée que si la conversion a réussi.

Les trois arguments du programme sont passés après [--] dans la commande [dotnet run]. Les résultats sont les suivants :

> dotnet run fichiers-04.cs -- personnes1.txt personnes1.bin logs.txt
9 lignes lues, 4 personnes écrites dans [personnes1.bin], erreurs dans [logs.txt]

Le fichier de logs [logs.txt] a le contenu suivant :

1
2
3
4
> cat logs.txt
La ligne n° [6] du fichier [personnes1.txt] a un âge [-1] incorrect
La ligne n° [8] du fichier [personnes1.txt] a un nombre de champs incorrect
La ligne n° [9] du fichier [personnes1.txt] a un âge [yy] incorrect

Le contenu du fichier binaire [personnes1.bin] peut être affiché en hexadécimal avec la commande Linux [od] (sous Windows, la commande PowerShell [Format-Hex] donne un affichage analogue) :

1
2
3
4
5
> od -A d -t x1z personnes1.bin
0000000 04 70 61 75 6c 0a 00 00 00 06 68 65 6c 65 6e 65  >.paul.....helene<
0000016 0f 00 00 00 07 6a 61 63 71 75 65 73 0b 00 00 00  >.....jacques....<
0000032 07 73 79 6c 76 61 69 6e 0c 00 00 00              >.sylvain....<
0000044

On y lit la façon dont [BinaryWriter] écrit les données :

  • une chaîne est écrite précédée de sa longueur en octets : l'octet 04 puis les 4 octets de « paul » (70 61 75 6c) ;
  • un entier [int] est écrit sur 4 octets, l'octet de poids faible en premier : 10 s'écrit 0a 00 00 00 ;
  • les données sont écrites les unes à la suite des autres, sans séparateur ni fin de ligne : le fichier fait 44 octets (ligne 5 : avec l'option [-A d], [od] affiche les positions en décimal).

Pour relire ce fichier, il faut donc connaître exactement l'ordre et le type des données écrites. C'est ce que fait le programme [fichiers-05.cs], qui réalise l'opération inverse : il lit le fichier binaire pour créer un fichier texte. Si le fichier texte produit contient les données correctes du fichier d'origine, on saura que la conversion texte → binaire → texte s'est bien passée.

// syntaxe : dotnet run fichiers-05.cs -- binaire texte
// on lit un fichier binaire (binaire) et on range son contenu dans un fichier texte (texte)
// le fichier binaire contient des couples (string, int)
// le fichier texte aura des lignes de la forme nom:âge

// il faut 2 arguments
if (args.Length != 2)
{
    Console.WriteLine("syntaxe : dotnet run fichiers-05.cs -- binaire texte");
    return 1;
}
try
{
    // ouverture du fichier binaire en lecture
    using var input = new BinaryReader(new FileStream(args[0], FileMode.Open, FileAccess.Read));
    // ouverture du fichier texte en écriture
    using var output = new StreamWriter(args[1]);
    // exploitation du fichier binaire, tant qu'on n'est pas à la fin du flux
    int nbPersonnes = 0;
    while (input.BaseStream.Position < input.BaseStream.Length)
    {
        // lecture nom, puis âge : dans l'ordre où ils ont été écrits
        string nom = input.ReadString();
        int age = input.ReadInt32();
        // écriture dans le fichier texte
        output.WriteLine($"{nom}:{age}");
        nbPersonnes++;
    }
    Console.WriteLine($"{nbPersonnes} personnes lues dans [{args[0]}] et écrites dans [{args[1]}]");
}
catch (EndOfStreamException)
{
    // le fichier se termine au milieu d'un enregistrement
    Console.WriteLine($"Le fichier [{args[0]}] est tronqué");
    return 2;
}
catch (FileNotFoundException e)
{
    // e.FileName est le chemin complet du fichier ; on n'en affiche que le nom
    Console.WriteLine($"Le fichier [{Path.GetFileName(e.FileName)}] n'existe pas");
    return 3;
}
catch (IOException e)
{
    Console.WriteLine($"L'erreur suivante s'est produite : {e.GetType().Name} : {e.Message}");
    return 4;
}
return 0;

Attardons-nous sur les opérations concernant la classe [BinaryReader] :

  • ligne 15 : l'objet [BinaryReader] est ouvert sur un flux [FileStream] avec le mode [FileMode.Open], pour ouvrir un fichier existant, et l'accès [FileAccess.Read], pour un accès en lecture ;
  • lignes 23-24 : les opérations de lecture. La classe [BinaryReader] dispose de méthodes [ReadXX] pour lire les différents types de données simples : [ReadString], [ReadInt32], [ReadDouble], [ReadDecimal], [ReadBoolean]... Elles doivent être appelées dans l'ordre des écritures ;
  • ligne 20 : la propriété [BaseStream] donne le flux sous-jacent, ici le [FileStream]. On lit tant que la position courante dans le fichier n'a pas atteint sa longueur. Le cours de 2008 lisait sans fin et détectait la fin du fichier par l'exception [EndOfStreamException] : une exception doit rester exceptionnelle, et la fin d'un fichier ne l'est pas ;
  • ligne 31 : cette exception signale maintenant une vraie anomalie : un fichier qui s'arrête au milieu d'un enregistrement.

Exécutons le programme sur le fichier [personnes1.bin] produit précédemment :

> dotnet run fichiers-05.cs -- personnes1.bin personnes2.txt
4 personnes lues dans [personnes1.bin] et écrites dans [personnes2.txt]

Le contenu de [personnes2.txt] est le suivant :

1
2
3
4
5
> cat personnes2.txt
paul:10
helene:15
jacques:11
sylvain:12

On retrouve les 4 personnes valides du fichier [personnes1.txt]. Avec un fichier [tronque.bin] formé des 30 premiers octets de [personnes1.bin], la lecture de l'âge de jacques échoue :

> dotnet run fichiers-05.cs -- tronque.bin tronque.txt
Le fichier [tronque.bin] est tronqué

5.7.2. La sérialisation JSON

Écrire et relire un fichier binaire donnée par donnée est fastidieux, et le moindre décalage entre l'écriture et la lecture produit des résultats faux. On préfère généralement sérialiser des objets : les transformer automatiquement en une suite d'octets ou de caractères, puis les désérialiser pour les reconstruire.

.NET proposait pour cela la classe [BinaryFormatter], qui sérialisait des objets dans un format binaire propre à .NET. Elle est aujourd'hui supprimée : elle a été déclarée obsolète en .NET 5 et son implémentation a été retirée en .NET 9, car désérialiser des données provenant d'une source non sûre avec [BinaryFormatter] permettait d'exécuter du code arbitraire. Microsoft recommande de la remplacer par la sérialisation JSON de la bibliothèque [System.Text.Json], intégrée à .NET depuis .NET Core 3.0.

JSON (JavaScript Object Notation) est un format texte, lisible, et universel : c'est le format d'échange des services web (chapitre 13) et de nombreux fichiers de configuration. Un objet y est écrit entre accolades sous la forme de couples "propriété": valeur, un tableau entre crochets.

La classe statique [System.Text.Json.JsonSerializer] a deux méthodes principales :

méthode
rôle
[string Serialize<T>(T value, JsonSerializerOptions? options = null)]
rend le texte JSON représentant l'objet [value]
[T? Deserialize<T>(string json, JsonSerializerOptions? options = null)]
reconstruit un objet de type [T] à partir du texte JSON [json] ; lance une [JsonException] si le texte est incorrect

Le programme [fichiers-06.cs] les utilise :

#:property PublishAot=false
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Unicode;

// sérialisation JSON avec System.Text.Json

List<Personne> personnes =
[
    new("paul", 10, ["football"]),
    new("hélène", 15, ["piano", "tennis"]),
    new("jacques", 11, []),
];

// 1 - sérialisation par défaut : une seule ligne, caractères non ASCII échappés
string json = JsonSerializer.Serialize(personnes[1]);
Console.WriteLine($"[par défaut] {json}");

// 2 - des options : indentation, caractères accentués laissés tels quels
var options = new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};
json = JsonSerializer.Serialize(personnes, options);
// écriture dans un fichier texte
File.WriteAllText("personnes.json", json);
Console.WriteLine("[personnes.json]");
Console.WriteLine(File.ReadAllText("personnes.json"));

// 3 - désérialisation : du texte JSON vers des objets
string texte = File.ReadAllText("personnes.json");
List<Personne>? relues = JsonSerializer.Deserialize<List<Personne>>(texte);
Console.WriteLine($"[relues] {relues?.Count} personnes");
foreach (Personne p in relues ?? [])
{
    Console.WriteLine($"  {p.Nom}, {p.Age} ans, loisirs : [{string.Join(", ", p.Loisirs)}]");
}

// 4 - sans attributs : les options « Web » mettent les noms de propriétés en camelCase
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
string jsonArticle = JsonSerializer.Serialize(new Article("A12", 12.5m), web);
Console.WriteLine($"[options Web] {jsonArticle}");
// et elles ignorent la casse à la lecture
Article? article = JsonSerializer.Deserialize<Article>("""{ "CODE": "B7", "prix": 3 }""", web);
Console.WriteLine($"[options Web] {article}");

// 5 - un document JSON incorrect provoque une JsonException
try
{
    JsonSerializer.Deserialize<Personne>("""{ "nom": "x", "age": "douze" }""");
}
catch (JsonException e)
{
    Console.WriteLine($"[erreur] {e.GetType().Name} : {e.Message}");
}

// une personne ; les attributs [JsonPropertyName] fixent le nom des propriétés JSON
record Personne(
    [property: JsonPropertyName("nom")] string Nom,
    [property: JsonPropertyName("age")] int Age,
    [property: JsonPropertyName("loisirs")] List<string> Loisirs);

// un article, sans attributs
record Article(string Code, decimal Prix);

Résultats :

> dotnet run fichiers-06.cs
[par défaut] {"nom":"h\u00E9l\u00E8ne","age":15,"loisirs":["piano","tennis"]}
[personnes.json]
[
  {
    "nom": "paul",
    "age": 10,
    "loisirs": [
      "football"
    ]
  },
  {
    "nom": "hélène",
    "age": 15,
    "loisirs": [
      "piano",
      "tennis"
    ]
  },
  {
    "nom": "jacques",
    "age": 11,
    "loisirs": []
  }
]
[relues] 3 personnes
  paul, 10 ans, loisirs : [football]
  hélène, 15 ans, loisirs : [piano, tennis]
  jacques, 11 ans, loisirs : []
[options Web] {"code":"A12","prix":12.5}
[options Web] Article { Code = B7, Prix = 3 }
[erreur] JsonException : The JSON value could not be converted to Personne. Path: $.age | LineNumber: 0 | BytePositionInLine: 28.

Commentaires :

  • ligne 1 : une directive des applications fichier unique. Par défaut, celles-ci sont préparées pour la compilation native AOT (ahead of time), qui produit un exécutable autonome mais interdit les mécanismes reposant sur l'exploration des types à l'exécution (la réflexion). Or [JsonSerializer] utilise par défaut la réflexion pour découvrir les propriétés des objets à sérialiser : sans cette directive, la compilation produit des avertissements et l'exécution échoue. Un projet [.csproj] ordinaire, comme celui de l'application exemple, n'a pas cette restriction. Pour une application compilée en natif, [System.Text.Json] propose un générateur de source (une classe dérivée de [JsonSerializerContext]) qui produit le code de sérialisation à la compilation : nous ne le présenterons pas ;
  • lignes 60-63 : l'enregistrement [Personne] à sérialiser. Ses propriétés portent l'attribut [JsonPropertyName], qui fixe le nom de la propriété dans le texte JSON : [nom] au lieu de [Nom]. Dans un enregistrement positionnel, [property: ...] indique que l'attribut s'applique à la propriété générée et non au paramètre du constructeur ;
  • ligne 17 : sérialisation d'un objet avec les options par défaut : le texte est écrit sur une seule ligne et les caractères non ASCII sont échappés : [é] devient [\u00E9]. Le texte reste correct, mais il est peu lisible ;
  • lignes 21-26 : l'option [WriteIndented] produit un texte indenté, sur plusieurs lignes ; l'option [Encoder] indique quels caractères laisser tels quels, ici tous ceux d'Unicode. Une liste est sérialisée en un tableau JSON ;
  • ligne 28 : le texte JSON est enregistré dans un fichier texte : c'est ainsi qu'on remplace aujourd'hui la sérialisation binaire ;
  • ligne 34 : la désérialisation reconstruit la liste. [Deserialize] crée les objets [Personne] en appelant le constructeur de l'enregistrement, dont les paramètres sont associés aux propriétés JSON de même nom. Le résultat est de type [List<Personne>?] : le texte JSON [null] est un document valide qui donne [null] ;
  • ligne 42 : les options « Web », celles qu'utilisent par défaut les services web d'ASP.NET Core, écrivent les noms de propriétés en camelCase (première lettre en minuscule) : [Code] devient [code], sans attribut. À la lecture, elles ne tiennent pas compte de la casse : [CODE] est accepté (ligne 31) ;
  • ligne 52 : un document JSON incorrect, ici une chaîne là où un nombre est attendu, provoque une exception [JsonException] dont le message indique l'emplacement de l'erreur.

Nous utiliserons JSON dans l'application exemple qui suit, puis tout au long du cours.

5.8. Les expressions régulières

La classe [System.Text.RegularExpressions.Regex] permet l'utilisation d'expressions régulières. Celles-ci permettent de tester le format d'une chaîne de caractères. Ainsi on peut vérifier qu'une chaîne représentant une date est bien au format jj/mm/aa. On utilise pour cela un modèle et on compare la chaîne à ce modèle. Dans cet exemple, j, m et a doivent être des chiffres. Le modèle d'un format de date valide est alors [\d\d/\d\d/\d\d] où le symbole [\d] désigne un chiffre. Les symboles utilisables dans un modèle sont les suivants :

caractère
description
[\]
marque le caractère suivant comme caractère spécial ou littéral. Par exemple, [n] correspond au caractère n, [\n] correspond à un caractère de nouvelle ligne, [\\] correspond à \, [\(] correspond à (
[^]
correspond au début de la chaîne
[$]
correspond à la fin de la chaîne
[*]
correspond à l'élément précédent zéro fois ou plusieurs fois. Ainsi [zo*] correspond à z ou à zoo
[+]
correspond à l'élément précédent une ou plusieurs fois. Ainsi [zo+] correspond à zoo, mais pas à z
[?]
correspond à l'élément précédent zéro ou une fois. Par exemple, [a?ve?] correspond à ve dans lever
[.]
correspond à tout caractère unique, sauf le caractère de nouvelle ligne
[(modèle)]
recherche le modèle et mémorise la correspondance (un groupe). La sous-chaîne correspondante peut être extraite de la collection [Groups] du résultat. Pour trouver des correspondances avec des caractères entre parenthèses, utilisez [\(] ou [\)]
[x|y]
correspond soit à x, soit à y. Par exemple, [z|foot] correspond à z ou à foot ; [(z|f)oo] correspond à zoo ou à foo
[{n}]
n est un entier non négatif. Correspond exactement n fois à l'élément précédent. Par exemple, [o{2}] ne correspond pas à o dans Bob, mais aux deux premiers o de fooooot
[{n,}]
correspond au moins n fois à l'élément précédent. Par exemple, [o{2,}] ne correspond pas à o dans Bob, mais à tous les o de fooooot. [o{1,}] équivaut à [o+] et [o{0,}] équivaut à [o*]
[{n,m}]
correspond au moins n fois et au plus m fois à l'élément précédent. Par exemple, [o{1,3}] correspond aux trois premiers o de foooooot ; [o{0,1}] équivaut à [o?]
[xyz]
jeu de caractères : correspond à l'un des caractères indiqués. Par exemple, [abc] correspond à a dans plat
[^xyz]
jeu de caractères négatif : correspond à tout caractère non indiqué. Par exemple, [^abc] correspond à p dans plat
[a-z]
plage de caractères : correspond à tout caractère de la série spécifiée, ici une lettre minuscule non accentuée
[^m-z]
plage de caractères négative : correspond à tout caractère ne se trouvant pas entre m et z
[\b]
correspond à une limite de mot, c'est-à-dire à la position entre un caractère de mot et un caractère qui n'en est pas un. Par exemple, [er\b] correspond à er dans lever, mais pas à er dans verbe
[\B]
correspond à une position qui n'est pas une limite de mot. [en*t\B] correspond à ent dans « bien entendu »
[\d]
correspond à un chiffre ; équivaut à [0-9] (avec .NET, aussi aux chiffres des autres alphabets Unicode)
[\D]
correspond à un caractère qui n'est pas un chiffre
[\f], [\n], [\r], [\t], [\v]
correspondent respectivement aux caractères saut de page, nouvelle ligne, retour chariot, tabulation, tabulation verticale
[\s]
correspond à tout espace blanc : espace, tabulation, saut de page, nouvelle ligne… Équivaut à [ \f\n\r\t\v], plus les autres espaces d'Unicode (espace insécable…)
[\S]
correspond à tout caractère qui n'est pas un espace blanc
[\w]
correspond à tout caractère de mot : lettre (accentuée ou non), chiffre ou trait de soulignement
[\W]
correspond à tout caractère qui n'est pas un caractère de mot
[\num]
num est un entier positif : fait référence au groupe n° num. Par exemple, [(.)\1] correspond à deux caractères identiques consécutifs
[\xnn], [\unnnn]
correspond au caractère de code hexadécimal nn (2 chiffres) ou nnnn (4 chiffres). Par exemple, [\x41] correspond à A

Un élément dans un modèle peut être présent en 1 ou plusieurs exemplaires. Considérons quelques exemples autour du symbole [\d] qui représente 1 chiffre :

modèle
signification
[\d]
un chiffre
[\d?]
0 ou 1 chiffre
[\d*]
0 ou davantage de chiffres
[\d+]
1 ou davantage de chiffres
[\d{2}]
2 chiffres
[\d{3,}]
au moins 3 chiffres
[\d{5,7}]
entre 5 et 7 chiffres

Imaginons maintenant le modèle capable de décrire le format attendu pour une chaîne de caractères :

chaîne recherchée
modèle
une date au format jj/mm/aa
[\d{2}/\d{2}/\d{2}]
une heure au format hh:mm:ss
[\d{2}:\d{2}:\d{2}]
un nombre entier non signé
[\d+]
une suite d'espaces éventuellement vide
[\s*]
un nombre entier non signé qui peut être précédé ou suivi d'espaces
[\s*\d+\s*]
un nombre entier qui peut être signé et précédé ou suivi d'espaces
[\s*[+-]?\s*\d+\s*]
un nombre réel non signé qui peut être précédé ou suivi d'espaces
[\s*\d+(\.\d*)?\s*]
un nombre réel qui peut être signé et précédé ou suivi d'espaces
[\s*[+-]?\s*\d+(\.\d*)?\s*]
une chaîne contenant le mot juste
[\bjuste\b]

Le cours de 2008 écrivait [+|-] pour le signe : entre crochets, le caractère [|] n'a pas de signification particulière, et ce modèle acceptait aussi le signe | devant un nombre. Il écrivait aussi [(.\d*)] pour la partie décimale : le point non échappé y désigne n'importe quel caractère. Les modèles ci-dessus sont corrigés.

On peut préciser où on recherche le modèle dans la chaîne :

modèle
signification
[^modèle]
le modèle commence la chaîne
[modèle$]
le modèle finit la chaîne
[^modèle$]
le modèle commence et finit la chaîne
[modèle]
le modèle est cherché partout dans la chaîne, en commençant par le début de celle-ci
chaîne recherchée
modèle
une chaîne se terminant par un point d'exclamation
[!$]
une chaîne se terminant par un point
[\.$]
une chaîne commençant par la séquence //
[^//]
une chaîne ne comportant qu'un mot, éventuellement suivi ou précédé d'espaces
[^\s*\w+\s*$]
une chaîne ne comportant que deux mots, éventuellement suivis ou précédés d'espaces
[^\s*\w+\s+\w+\s*$]
une chaîne contenant le mot secret
[\bsecret\b]

Les sous-ensembles d'un modèle peuvent être « récupérés ». Ainsi, non seulement on peut vérifier qu'une chaîne correspond à un modèle particulier, mais on peut récupérer dans cette chaîne les éléments correspondant aux sous-ensembles du modèle qui ont été entourés de parenthèses. Ainsi, si on analyse une chaîne contenant une date jj/mm/aa et si on veut de plus récupérer les éléments jj, mm, aa de cette date, on utilisera le modèle [(\d\d)/(\d\d)/(\d\d)].

Dans le code C#, un modèle est écrit de préférence dans une chaîne verbatim [@"..."] : les nombreux caractères [] des modèles n'ont alors pas à être doublés. Avec une chaîne ordinaire, il faudrait écrire ["\\d+"] au lieu de [@"\d+"].

5.8.1. Vérifier qu'une chaîne correspond à un modèle donné

Un objet de type [Regex] se construit de la façon suivante :

constructeur
rôle
[public Regex(string pattern)]
construit un objet « expression régulière » à partir d'un modèle passé en paramètre ([pattern]). Une exception [ArgumentException] est lancée si le modèle est incorrect
[public Regex(string pattern, RegexOptions options, TimeSpan matchTimeout)]
idem, avec des options et un délai d'expiration (voir plus loin)

Une fois l'expression régulière construite, on peut la comparer à des chaînes de caractères avec la méthode [IsMatch] :

méthode
rôle
[public bool IsMatch(string input)]
vrai si la chaîne [input] contient une partie correspondant au modèle de l'expression régulière

Le programme [regex-01.cs] est le suivant :

using System.Text.RegularExpressions;

// vérifier qu'une chaîne correspond à un modèle
string modele1 = @"^\s*\d+\s*$";
var regex1 = new Regex(modele1);
// comparer des exemplaires au modèle
foreach (string exemplaire in new[] { "  123  ", "  123a  ", "4567" })
{
    if (regex1.IsMatch(exemplaire))
    {
        Console.WriteLine($"[{exemplaire}] correspond au modèle [{modele1}]");
    }
    else
    {
        Console.WriteLine($"[{exemplaire}] ne correspond pas au modèle [{modele1}]");
    }
}
// la méthode statique Regex.IsMatch évite de créer l'objet Regex soi-même
Console.WriteLine($"Regex.IsMatch(\"18/05/97\", date) : {Regex.IsMatch("18/05/97", @"^\d{2}/\d{2}/\d{2}$")}");
Console.WriteLine($"Regex.IsMatch(\"5/1/01\", date) : {Regex.IsMatch("5/1/01", @"^\d{2}/\d{2}/\d{2}$")}");

Résultats :

1
2
3
4
5
6
> dotnet run regex-01.cs
[  123  ] correspond au modèle [^\s*\d+\s*$]
[  123a  ] ne correspond pas au modèle [^\s*\d+\s*$]
[4567] correspond au modèle [^\s*\d+\s*$]
Regex.IsMatch("18/05/97", date) : True
Regex.IsMatch("5/1/01", date) : False

Commentaires :

  • ligne 4 : le modèle d'un nombre entier non signé, éventuellement entouré d'espaces, qui occupe toute la chaîne (ancres [^] et [$]) ;
  • ligne 9 : la chaîne « 123a », entourée d'espaces, ne correspond pas au modèle : le caractère a ne peut être ni un chiffre, ni un espace. Sans les ancres [^] et [$], la chaîne correspondrait, car elle contient un nombre ;
  • ligne 19 : la classe [Regex] a aussi des méthodes statiques, qui reçoivent le modèle en paramètre. .NET garde en mémoire les derniers modèles utilisés : on peut les employer dans une boucle sans craindre que le modèle soit analysé à chaque appel.

5.8.2. Trouver toutes les occurrences d'un modèle dans une chaîne

La méthode [Matches] permet de récupérer les éléments d'une chaîne correspondant à un modèle :

méthode
rôle
[public MatchCollection Matches(string input)]
rend la collection des éléments de la chaîne [input] correspondant au modèle

La classe [MatchCollection] a une propriété [Count] qui est le nombre d'éléments de la collection. Si [resultats] est un objet [MatchCollection], [resultats[i]] est l'élément i de cette collection ; il est de type [Match]. La classe [Match] a diverses propriétés dont les suivantes :

  • [Value] : la valeur de l'objet [Match], donc un élément correspondant au modèle ;
  • [Index] : la position où l'élément a été trouvé dans la chaîne explorée.

Le programme [regex-02.cs] est le suivant :

using System.Text.RegularExpressions;

// trouver toutes les occurrences d'un modèle dans une chaîne
string modele2 = @"\d+";
var regex2 = new Regex(modele2);
string exemplaire3 = "  123  456  789 ";
MatchCollection resultats = regex2.Matches(exemplaire3);
Console.WriteLine($"Modèle=[{modele2}], exemplaire=[{exemplaire3}]");
Console.WriteLine($"Il y a {resultats.Count} occurrences du modèle dans l'exemplaire");
foreach (Match m in resultats)
{
    Console.WriteLine($"[{m.Value}] trouvé en position {m.Index}");
}
// une MatchCollection est une séquence : LINQ s'y applique
int somme = resultats.Sum(m => int.Parse(m.Value));
Console.WriteLine($"somme des nombres trouvés : {somme}");
// Count compte les occurrences sans construire la collection
Console.WriteLine($"nombre de voyelles de \"anticonstitutionnellement\" : {Regex.Count("anticonstitutionnellement", "[aeiouy]")}");

Résultats :

1
2
3
4
5
6
7
8
> dotnet run regex-02.cs
Modèle=[\d+], exemplaire=[  123  456  789 ]
Il y a 3 occurrences du modèle dans l'exemplaire
[123] trouvé en position 2
[456] trouvé en position 7
[789] trouvé en position 12
somme des nombres trouvés : 1368
nombre de voyelles de "anticonstitutionnellement" : 10

Commentaires :

  • ligne 4 : le modèle recherché est une suite de chiffres ;
  • ligne 6 : la chaîne dans laquelle on recherche ce modèle ;
  • ligne 7 : on récupère tous les éléments de [exemplaire3] vérifiant le modèle [modele2] ;
  • lignes 10-12 : on les affiche. Une [MatchCollection] se parcourt avec [foreach], comme une liste ;
  • ligne 15 : [MatchCollection] implémente [IEnumerable<Match>] : LINQ s'y applique. On fait ici la somme des nombres trouvés ;
  • ligne 18 : la méthode [Count] (.NET 7) compte les occurrences du modèle sans construire de collection. Le modèle [aeiouy] désigne une voyelle.

5.8.3. Récupérer des parties d'un modèle

Comme nous l'avons dit, les parties d'un modèle entourées de parenthèses sont mémorisées : ce sont les groupes. Le programme [regex-03.cs] les utilise :

using System.Text.RegularExpressions;

// récupérer des parties d'un modèle : les groupes
string modele3 = @"(\d\d):(\d\d):(\d\d)";
var regex3 = new Regex(modele3);
string exemplaire4 = "Il est 18:05:49";
// Match rend la première occurrence du modèle
Match resultat = regex3.Match(exemplaire4);
if (resultat.Success)
{
    Console.WriteLine($"L'exemplaire [{exemplaire4}] correspond au modèle [{modele3}]");
    // on affiche les groupes de parenthèses
    for (int i = 0; i < resultat.Groups.Count; i++)
    {
        Console.WriteLine($"groupes[{i}]=[{resultat.Groups[i].Value}] trouvé en position {resultat.Groups[i].Index}");
    }
}
else
{
    Console.WriteLine($"L'exemplaire [{exemplaire4}] ne correspond pas au modèle [{modele3}]");
}

// des groupes nommés : (?<nom>...)
var regexDate = new Regex(@"(?<jour>\d{1,2})/(?<mois>\d{1,2})/(?<annee>\d{4})");
string texte = "Rendez-vous le 3/10/2026, puis le 15/11/2026.";
foreach (Match m in regexDate.Matches(texte))
{
    // un groupe se désigne par son nom plutôt que par son numéro
    int jour = int.Parse(m.Groups["jour"].Value);
    int mois = int.Parse(m.Groups["mois"].Value);
    int annee = int.Parse(m.Groups["annee"].Value);
    var date = new DateOnly(annee, mois, jour);
    Console.WriteLine($"[{m.Value}] : jour={jour}, mois={mois}, année={annee} -> {date:dddd d MMMM yyyy}");
}

// Replace : les groupes peuvent être réutilisés dans la chaîne de remplacement
string inverse = regexDate.Replace(texte, "${annee}-${mois}-${jour}");
Console.WriteLine($"Replace : {inverse}");

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run regex-03.cs
L'exemplaire [Il est 18:05:49] correspond au modèle [(\d\d):(\d\d):(\d\d)]
groupes[0]=[18:05:49] trouvé en position 7
groupes[1]=[18] trouvé en position 7
groupes[2]=[05] trouvé en position 10
groupes[3]=[49] trouvé en position 13
[3/10/2026] : jour=3, mois=10, année=2026 -> samedi 3 octobre 2026
[15/11/2026] : jour=15, mois=11, année=2026 -> dimanche 15 novembre 2026
Replace : Rendez-vous le 2026-10-3, puis le 2026-11-15.

Commentaires :

  • ligne 8 : la chaîne [exemplaire4] est comparée au modèle [regex3] au travers de la méthode [Match]. Celle-ci rend la première occurrence du modèle, sous la forme d'un objet [Match] déjà présenté. Nous utilisons ici deux nouvelles propriétés de cette classe :
    • [Success] (ligne 9) : indique s'il y a eu correspondance ;
    • [Groups] (ligne 13) : collection où [Groups[0]] correspond à la partie de la chaîne correspondant au modèle et [Groups[i]] (i >= 1) correspond au groupe de parenthèses n° i ;
  • si [resultat] est de type [Match], [resultat.Groups] est de type [GroupCollection] et [resultat.Groups[i]] de type [Group]. La classe [Group] a, comme la classe [Match], les propriétés [Value] (le texte capturé par le groupe) et [Index] (sa position dans la chaîne explorée) ;
  • ligne 24 : un groupe peut être nommé avec la syntaxe [(?<nom>...)]. Le modèle reconnaît ici une date au format j/m/aaaa, avec un jour et un mois d'un ou deux chiffres ;
  • lignes 29-31 : un groupe nommé se désigne par son nom plutôt que par son numéro : le code est plus lisible et ne dépend pas de l'ordre des parenthèses ;
  • ligne 32 : le type [DateOnly] (.NET 6) représente une date sans heure. Il est affiché avec le format [dddd d MMMM yyyy] : le jour de la semaine apparaît, en français ;
  • ligne 37 : la méthode [Replace] remplace chaque occurrence du modèle par une chaîne dans laquelle [${nom}] (ou [$1], [$2]… pour les groupes numérotés) désigne la valeur d'un groupe. Les dates sont ainsi récrites au format année-mois-jour.

5.8.4. Un programme d'apprentissage

Trouver l'expression régulière qui permet de vérifier qu'une chaîne correspond bien à un certain modèle est parfois un véritable défi. Le programme [regex-04.cs] permet de s'entraîner. Il demande un modèle et une chaîne et indique si la chaîne correspond ou non au modèle.

using System.Text.RegularExpressions;

// un programme d'apprentissage des expressions régulières
while (true)
{
    // on demande le modèle
    Console.Write("Tapez le modèle à tester ou rien pour arrêter : ");
    string modele = Console.ReadLine() ?? "";
    // fini ?
    if (modele.Trim() == "")
    {
        break;
    }
    // on crée l'expression régulière ; un modèle incorrect provoque une exception
    Regex regex;
    try
    {
        regex = new Regex(modele, RegexOptions.None, TimeSpan.FromSeconds(1));
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"Erreur : {e.Message}");
        continue;
    }
    // on demande les exemplaires à comparer au modèle
    while (true)
    {
        Console.Write($"Tapez la chaîne à comparer au modèle [{modele}] ou rien pour arrêter : ");
        string chaine = Console.ReadLine() ?? "";
        // fini ?
        if (chaine.Trim() == "")
        {
            break;
        }
        try
        {
            // on fait la comparaison
            MatchCollection resultats = regex.Matches(chaine);
            if (resultats.Count == 0)
            {
                Console.WriteLine("Je n'ai pas trouvé de correspondances");
                continue;
            }
            // on affiche les éléments correspondant au modèle
            foreach (Match m in resultats)
            {
                Console.WriteLine($"J'ai trouvé la correspondance [{m.Value}] en position [{m.Index}]");
                // et ses sous-éléments (groupes 1, 2, ...)
                for (int j = 1; j < m.Groups.Count; j++)
                {
                    Console.WriteLine($"\tsous-élément [{m.Groups[j].Value}] en position [{m.Groups[j].Index}]");
                }
            }
        }
        catch (RegexMatchTimeoutException)
        {
            Console.WriteLine("La recherche a été abandonnée : elle durait plus d'une seconde");
        }
    }
}

Commentaires :

  • ligne 18 : l'expression régulière est construite avec un délai d'expiration d'une seconde : si une recherche dure plus longtemps, elle est abandonnée et une exception [RegexMatchTimeoutException] est lancée (ligne 55). Nous verrons plus loin pourquoi c'est utile ;
  • ligne 20 : un modèle incorrect provoque une exception [ArgumentException] (plus précisément [RegexParseException], qui en dérive) ;
  • lignes 45-51 : pour chaque correspondance, on affiche les groupes n° 1 et suivants, s'il y en a.

Voici un exemple d'exécution :

> dotnet run regex-04.cs
Tapez le modèle à tester ou rien pour arrêter : \d+
Tapez la chaîne à comparer au modèle [\d+] ou rien pour arrêter : 123 456 789
J'ai trouvé la correspondance [123] en position [0]
J'ai trouvé la correspondance [456] en position [4]
J'ai trouvé la correspondance [789] en position [8]
Tapez la chaîne à comparer au modèle [\d+] ou rien pour arrêter : 
Tapez le modèle à tester ou rien pour arrêter : (\d{2}):(\d\d)
Tapez la chaîne à comparer au modèle [(\d{2}):(\d\d)] ou rien pour arrêter : 14:15 abcd 17:18 xyzt
J'ai trouvé la correspondance [14:15] en position [0]
    sous-élément [14] en position [0]
    sous-élément [15] en position [3]
J'ai trouvé la correspondance [17:18] en position [11]
    sous-élément [17] en position [11]
    sous-élément [18] en position [14]
Tapez la chaîne à comparer au modèle [(\d{2}):(\d\d)] ou rien pour arrêter : 
Tapez le modèle à tester ou rien pour arrêter : ^\s*\d+\s*$
Tapez la chaîne à comparer au modèle [^\s*\d+\s*$] ou rien pour arrêter :    1456
J'ai trouvé la correspondance [   1456] en position [0]
Tapez la chaîne à comparer au modèle [^\s*\d+\s*$] ou rien pour arrêter : 
Tapez le modèle à tester ou rien pour arrêter : ^\s*(\d+)\s*$
Tapez la chaîne à comparer au modèle [^\s*(\d+)\s*$] ou rien pour arrêter : 1456
J'ai trouvé la correspondance [1456] en position [0]
    sous-élément [1456] en position [0]
Tapez la chaîne à comparer au modèle [^\s*(\d+)\s*$] ou rien pour arrêter : abcd 1456
Je n'ai pas trouvé de correspondances
Tapez la chaîne à comparer au modèle [^\s*(\d+)\s*$] ou rien pour arrêter : 
Tapez le modèle à tester ou rien pour arrêter : (a+)+$
Tapez la chaîne à comparer au modèle [(a+)+$] ou rien pour arrêter : aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
La recherche a été abandonnée : elle durait plus d'une seconde
Tapez la chaîne à comparer au modèle [(a+)+$] ou rien pour arrêter : 
Tapez le modèle à tester ou rien pour arrêter : (\d+
Erreur : Invalid pattern '(\d+' at offset 4. Not enough )'s.
Tapez le modèle à tester ou rien pour arrêter : 

Les modèles [^\s*\d+\s*$] et [^\s*(\d+)\s*$] montrent l'intérêt des groupes : le premier reconnaît la chaîne « 1456 » précédée de trois espaces, espaces compris ; le second permet en plus d'en extraire le nombre, sans les espaces. Le modèle [(a+)+$] est examiné ci-dessous.

5.8.5. La méthode Split

Nous avons déjà rencontré la méthode [Split] de la classe [String] :

méthode
rôle
[public string[] Split(params char[] separator)]
la chaîne est vue comme une suite de champs séparés par les caractères présents dans le tableau [separator]. Le résultat est le tableau de ces champs

La méthode [Split] de la classe [Regex] nous permet d'exprimer le séparateur à l'aide d'un modèle :

méthode
rôle
[public string[] Split(string input)]
la chaîne [input] est décomposée en champs, ceux-ci étant séparés par un séparateur correspondant au modèle de l'objet [Regex] courant

Supposons par exemple qu'on ait dans un fichier texte des lignes de la forme champ1, champ2, ..., champn. Les champs sont séparés par une virgule, mais celle-ci peut être précédée ou suivie d'espaces. Le modèle [\s*,\s*] décrit exactement ce séparateur, comme le montre le programme [regex-05.cs] :

using System.Text.RegularExpressions;

// Regex.Split : le séparateur est défini par un modèle
string ligne = "abc  , def  ,ghi ,   jkl";
// une virgule précédée et suivie d'un nombre quelconque d'espaces
var separateur = new Regex(@"\s*,\s*");
string[] champs = separateur.Split(ligne);
for (int i = 0; i < champs.Length; i++)
{
    Console.WriteLine($"champs[{i}]=[{champs[i]}]");
}
// plusieurs séparateurs possibles : ; ou | ou une suite d'espaces
string[] mots = Regex.Split("un;deux|trois   quatre", @"[;|]|\s+");
Console.WriteLine($"mots=[{string.Join(", ", mots)}]");

Résultats :

1
2
3
4
5
6
> dotnet run regex-05.cs
champs[0]=[abc]
champs[1]=[def]
champs[2]=[ghi]
champs[3]=[jkl]
mots=[un, deux, trois, quatre]

Commentaires :

  • ligne 6 : le séparateur : une virgule précédée et suivie d'un nombre quelconque d'espaces. Les espaces disparaissent avec le séparateur. On notera que l'option [TrimEntries] de [string.Split], présentée plus haut, permet aujourd'hui d'obtenir le même résultat sans expression régulière ;
  • ligne 13 : un séparateur qui peut prendre plusieurs formes : un point-virgule ou une barre verticale ([;|]), ou bien ([|]) une suite d'espaces ([\s+]). Ici, une expression régulière est nécessaire.

5.8.6. Expressions régulières générées, options, délai d'expiration

Un objet [Regex] analyse son modèle à l'exécution, puis le traduit en une structure interne qu'il interprète, ou qu'il compile en code exécutable si on lui passe l'option [RegexOptions.Compiled]. Depuis .NET 7, l'attribut [GeneratedRegex] permet de faire ce travail à la compilation : un générateur de source écrit le code C# de la recherche, que l'on peut consulter dans l'éditeur (F12 sur la méthode). Le modèle est vérifié à la compilation, il n'y a plus de coût d'analyse au démarrage et le code produit est le plus rapide possible. C'est aujourd'hui la façon recommandée d'utiliser un modèle connu à l'avance. Le programme [regex-06.cs] est le suivant :

using System.Diagnostics;
using System.Text.RegularExpressions;

// 1 - des expressions régulières générées à la compilation
Console.WriteLine($"Heure(\"Il est 18:05:49\") : {Modeles.Heure().IsMatch("Il est 18:05:49")}");
Match m = Modeles.Heure().Match("départ à 07:45:00");
Console.WriteLine($"heures={m.Groups["h"].Value}, minutes={m.Groups["m"].Value}, secondes={m.Groups["s"].Value}");

// 2 - les options : ici IgnoreCase, fixée dans l'attribut
foreach (string reponse in new[] { "oui", "OUI", "Oui", "non", "ouiii" })
{
    Console.Write($"{reponse}={Modeles.Oui().IsMatch(reponse)} ");
}
Console.WriteLine();
// les options se combinent avec | : Multiline fait correspondre ^ et $ à chaque début et fin de ligne
string texte = "prix: 10\nquantité: 3\ntotal: 30";
foreach (Match ligne in Regex.Matches(texte, @"^(\w+): (\d+)$", RegexOptions.Multiline))
{
    Console.Write($"[{ligne.Groups[1].Value}={ligne.Groups[2].Value}] ");
}
Console.WriteLine();

// 3 - un délai d'expiration protège contre les modèles à retour arrière catastrophique
string chaine = new string('a', 30) + "!";
try
{
    Console.WriteLine(Modeles.Catastrophe().IsMatch(chaine));
}
catch (RegexMatchTimeoutException e)
{
    Console.WriteLine($"{e.GetType().Name} : recherche abandonnée au bout de {e.MatchTimeout.TotalMilliseconds} ms");
}
// le même modèle avec le moteur sans retour arrière répond immédiatement
var chrono = Stopwatch.StartNew();
bool trouve = Regex.IsMatch(chaine, @"^(a+)+$", RegexOptions.NonBacktracking);
Console.WriteLine($"NonBacktracking : {trouve}, réponse en moins d'une seconde : {chrono.ElapsedMilliseconds < 1000}");

// les modèles du programme
static partial class Modeles
{
    // une heure hh:mm:ss avec des groupes nommés
    [GeneratedRegex(@"(?<h>\d\d):(?<m>\d\d):(?<s>\d\d)")]
    public static partial Regex Heure();

    // oui, quelle que soit la casse
    [GeneratedRegex(@"^\s*oui\s*$", RegexOptions.IgnoreCase)]
    public static partial Regex Oui();

    // un modèle pathologique, avec un délai d'expiration de 100 ms
    [GeneratedRegex(@"^(a+)+$", RegexOptions.None, matchTimeoutMilliseconds: 100)]
    public static partial Regex Catastrophe();
}

Résultats :

1
2
3
4
5
6
7
> dotnet run regex-06.cs
Heure("Il est 18:05:49") : True
heures=07, minutes=45, secondes=00
oui=True OUI=True Oui=True non=False ouiii=False 
[prix=10] [quantité=3] [total=30] 
RegexMatchTimeoutException : recherche abandonnée au bout de 100 ms
NonBacktracking : False, réponse en moins d'une seconde : True

Commentaires :

  • lignes 39-51 : chaque modèle est déclaré par une méthode statique partielle ([partial]), sans corps, rendant un [Regex], et précédée de l'attribut [GeneratedRegex]. Le générateur de source écrit le corps de la méthode dans une autre partie de la classe, elle aussi déclarée [partial]. Depuis C# 13 et .NET 9, on peut aussi déclarer une propriété partielle : [[GeneratedRegex(...)] private static partial Regex Heure { get; }] ;
  • ligne 5 : on utilise la méthode comme un objet [Regex] ordinaire ;
  • ligne 46 : le second paramètre de l'attribut est une combinaison d'options [RegexOptions]. [IgnoreCase] ignore la casse : « OUI » et « Oui » correspondent au modèle ;
  • ligne 17 : les options s'utilisent de la même façon avec les méthodes de [Regex]. [Multiline] fait correspondre [^] et [$] au début et à la fin de chaque ligne de la chaîne, et non plus seulement au début et à la fin de la chaîne. D'autres options sont utiles : [Singleline] ([.] correspond aussi au saut de ligne), [IgnorePatternWhitespace] (les espaces du modèle sont ignorés, ce qui permet de l'écrire sur plusieurs lignes, commentées), [ExplicitCapture] (seuls les groupes nommés sont mémorisés). Elles se combinent avec l'opérateur [|] ;
  • ligne 50 : le modèle [^(a+)+$] est pathologique : appliqué à une suite de a terminée par un autre caractère, le moteur d'expressions régulières essaie toutes les façons de répartir les a entre les répétitions du groupe avant de conclure à l'échec. Leur nombre double à chaque a supplémentaire : avec 30 a, il y en a environ un milliard. On parle de retour arrière catastrophique. Un tel modèle, appliqué à des chaînes saisies par des utilisateurs, est une faille de sécurité : quelques requêtes suffisent à bloquer un serveur. Le paramètre [matchTimeoutMilliseconds] fixe un délai d'expiration de 100 ms : au-delà, la recherche est abandonnée avec une exception [RegexMatchTimeoutException] (ligne 6) ;
  • ligne 35 : l'option [NonBacktracking] (.NET 7) utilise un autre moteur, qui garantit un temps de recherche proportionnel à la longueur de la chaîne. Il ne permet pas certaines constructions (les références arrière [\1], par exemple), mais il répond ici immédiatement.

On retiendra qu'il faut fixer un délai d'expiration, ou utiliser [NonBacktracking], dès que le modèle ou la chaîne explorée proviennent de l'extérieur du programme.

5.9. Application exemple - V3

Nous reprenons l'application étudiée au chapitre 3 (version 1) et au chapitre 4 (version 2). Rappelons qu'il s'agit d'un exercice : le barème utilisé est celui de 2004 (revenus 2003), conservé tel quel depuis le cours d'origine.

5.9.1. Les données dans un fichier

Dans la version 2, le calcul de l'impôt se faisait dans la classe abstraite [AbstractImpot], que nous recopions ici sans changement, si ce n'est son espace de noms :

namespace ImpotsV3;

public abstract class AbstractImpot : IImpot
{
    // les tranches d'impôt nécessaires au calcul proviennent d'une source extérieure
    // que seules les classes dérivées connaissent
    protected abstract TrancheImpot[] TranchesImpot { get; }

    // calcul de l'impôt
    public int Calculer(bool marie, int nbEnfants, int salaire)
    {
        // calcul du nombre de parts
        decimal nbParts = (decimal)nbEnfants / 2 + (marie ? 2 : 1);
        if (nbEnfants >= 3)
        {
            nbParts += 0.5m;
        }
        // calcul du revenu imposable et du quotient familial
        decimal revenu = 0.72m * salaire;
        decimal quotient = revenu / nbParts;
        // recherche de la tranche d'impôt : la dernière sert si aucune limite ne convient
        TrancheImpot[] tranches = TranchesImpot;
        TrancheImpot tranche = tranches[^1];
        for (int i = 0; i < tranches.Length - 1; i++)
        {
            if (quotient <= tranches[i].Limite)
            {
                tranche = tranches[i];
                break;
            }
        }
        // calcul de l'impôt
        return (int)(revenu * tranche.CoeffR - nbParts * tranche.CoeffN);
    }
}

La méthode [Calculer] utilise les tranches d'impôt rendues par la propriété abstraite [TranchesImpot] (ligne 7). La classe [AbstractImpot] ne sait pas d'où viennent ces tranches : c'est aux classes dérivées de les fournir. Dans la version 2, la classe dérivée [HardwiredImpot] les plaçait « en dur » dans son code. Une tranche est représentée, comme dans la version 2, par la structure enregistrement [TrancheImpot] :

1
2
3
4
namespace ImpotsV3;

// une tranche du barème de l'impôt
public readonly record struct TrancheImpot(decimal Limite, decimal CoeffR, decimal CoeffN);

et le calcul est décrit par l'interface [IImpot] :

1
2
3
4
5
6
7
namespace ImpotsV3;

public interface IImpot
{
    // calcul de l'impôt d'un contribuable
    int Calculer(bool marie, int nbEnfants, int salaire);
}

La nouvelle version place les données dans un fichier, ce qui permet de changer de barème sans recompiler le programme. Nous écrivons deux nouvelles classes dérivées de [AbstractImpot] :

  • [FileImpot] lit les tranches dans un fichier texte [DataImpot.txt] ;
  • [JsonImpot] les lit dans un fichier JSON [DataImpot.json].

Le fichier texte [DataImpot.txt] contient une tranche par ligne, sous la forme limite:coeffR:coeffN :

1
2
3
4
5
6
7
4962:0:0
8382:0.068:291.09
14753:0.191:1322.92
23888:0.283:2668.39
38868:0.374:4846.98
47932:0.426:6883.66
0:0.481:9505.54

Le cours de 2008 écrivait les nombres avec une virgule décimale (0,068) et les lisait avec [decimal.TryParse] dans la culture de la machine : le même fichier, lu sur une machine anglaise ou américaine, aurait été refusé ou, pire, mal interprété. Un fichier de données ne doit pas dépendre de la langue de la machine qui le lit : nous écrivons les nombres avec un point décimal et les lisons dans la culture invariante ([CultureInfo.InvariantCulture]), celle qu'utilisent aussi JSON et la plupart des formats de données.

Le fichier [DataImpot.json] contient les mêmes données, sous la forme d'un tableau JSON d'objets :

1
2
3
4
5
6
7
8
9
[
  { "limite": 4962, "coeffR": 0, "coeffN": 0 },
  { "limite": 8382, "coeffR": 0.068, "coeffN": 291.09 },
  { "limite": 14753, "coeffR": 0.191, "coeffN": 1322.92 },
  { "limite": 23888, "coeffR": 0.283, "coeffN": 2668.39 },
  { "limite": 38868, "coeffR": 0.374, "coeffN": 4846.98 },
  { "limite": 47932, "coeffR": 0.426, "coeffN": 6883.66 },
  { "limite": 0, "coeffR": 0.481, "coeffN": 9505.54 }
]

5.9.2. Les erreurs

L'exploitation de ces fichiers peut échouer : fichier absent, ligne mal formée, nombre incorrect... Comme dans la version de 2008, nous créons une classe d'exception spéciale pour signaler ces erreurs. Le cours de 2008 l'appelait [FileImpotException] ; nous la nommons [ImpotException], car elle servira aussi pour les autres sources de données (fichier JSON ici, base de données plus tard). Elle porte un code d'erreur :

namespace ImpotsV3;

// les codes d'erreur : des puissances de 2, qui peuvent se combiner
[Flags]
public enum CodeErreur
{
    Acces = 1,      // le fichier ne peut pas être lu
    Ligne = 2,      // une ligne n'a pas trois champs
    Champ1 = 4,     // le champ 1 (limite) est incorrect
    Champ2 = 8,     // le champ 2 (coeffR) est incorrect
    Champ3 = 16,    // le champ 3 (coeffN) est incorrect
    Json = 32       // le document JSON est incorrect
}

// l'exception lancée par les classes qui lisent les tranches d'impôt
public class ImpotException : Exception
{
    // le code de l'erreur (une combinaison de valeurs CodeErreur)
    public int Code { get; init; }

    public ImpotException(string message) : base(message) { }
    public ImpotException(string message, Exception inner) : base(message, inner) { }
}

Commentaires :

  • lignes 16-22 : la classe [ImpotException] dérive de la classe [Exception]. Elle servira à signaler toute erreur survenant lors de l'exploitation du fichier des données. Elle a deux constructeurs : le premier reçoit un message d'erreur, le second reçoit en plus l'exception à l'origine de l'erreur, qui sera disponible dans la propriété [InnerException] ;
  • ligne 19 : la propriété [Code] mémorise le code de l'erreur. Le mot clé [init] permet de la fixer uniquement à la création de l'objet, avec un initialiseur d'objet : [new ImpotException("...") { Code = 2 }]. C'est cette forme d'exception, avec un code entier, que reprendront les versions suivantes de l'application ;
  • lignes 4-12 : une énumération des codes d'erreur possibles :
    • [Acces] : erreur d'accès au fichier des données ;
    • [Ligne] : ligne n'ayant pas les trois champs attendus, ou fichier sans aucune tranche ;
    • [Champ1], [Champ2], [Champ3] : le champ n° 1, 2 ou 3 d'une ligne est incorrect ;
    • [Json] : le document JSON est incorrect.

Certaines de ces erreurs peuvent se combiner : les trois champs d'une ligne peuvent être incorrects. Aussi l'énumération [CodeErreur] a-t-elle été annotée avec l'attribut [Flags], et ses valeurs sont des puissances de 2 : chacune correspond à un bit de l'entier. Une erreur sur les champs 2 et 3 se traduira par le code [Champ2 | Champ3], soit 8 + 16 = 24. Grâce à l'attribut [Flags], la méthode [ToString] de l'énumération affiche une telle combinaison sous la forme « Champ2, Champ3 ».

5.9.3. La classe FileImpot

La classe [FileImpot] fournit les tranches lues dans un fichier texte :

using System.Globalization;
using System.Text.RegularExpressions;

namespace ImpotsV3;

// les tranches d'impôt lues dans un fichier texte de lignes limite:coeffR:coeffN
public partial class FileImpot : AbstractImpot
{
    // le séparateur des champs : le caractère : entouré d'espaces éventuels
    [GeneratedRegex(@"\s*:\s*")]
    private static partial Regex Separateur();

    // les tranches d'impôt lues dans le fichier
    private readonly TrancheImpot[] tranchesImpot;

    // implémentation de la propriété abstraite de la classe parent
    protected override TrancheImpot[] TranchesImpot => tranchesImpot;

    public FileImpot(string nomFichier)
    {
        // le nom du fichier, sans son chemin, pour les messages d'erreur
        string nom = Path.GetFileName(nomFichier);
        // les tranches lues ; on ne connaît pas leur nombre à l'avance
        List<TrancheImpot> tranches = [];
        try
        {
            int numLigne = 0;
            foreach (string ligneLue in File.ReadLines(nomFichier))
            {
                numLigne++;
                string ligne = ligneLue.Trim();
                // on ignore les lignes vides
                if (ligne == "")
                {
                    continue;
                }
                // la ligne doit avoir trois champs
                string[] champs = Separateur().Split(ligne);
                if (champs.Length != 3)
                {
                    throw new ImpotException($"Ligne n° {numLigne} incorrecte ({ligne})")
                    {
                        Code = (int)CodeErreur.Ligne
                    };
                }
                // trois nombres décimaux positifs ou nuls, écrits avec un point décimal
                CodeErreur code = 0;
                if (!Lire(champs[0], out decimal limite)) code |= CodeErreur.Champ1;
                if (!Lire(champs[1], out decimal coeffR)) code |= CodeErreur.Champ2;
                if (!Lire(champs[2], out decimal coeffN)) code |= CodeErreur.Champ3;
                if (code != 0)
                {
                    throw new ImpotException($"Ligne n° {numLigne} incorrecte ({ligne})")
                    {
                        Code = (int)code
                    };
                }
                // on mémorise la nouvelle tranche d'impôt
                tranches.Add(new TrancheImpot(limite, coeffR, coeffN));
            }
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            // erreur d'accès au fichier : on l'encapsule dans une ImpotException
            throw new ImpotException($"Erreur lors de la lecture du fichier {nom}", e)
            {
                Code = (int)CodeErreur.Acces
            };
        }
        // il faut au moins une tranche
        if (tranches.Count == 0)
        {
            throw new ImpotException($"Le fichier {nom} ne contient aucune tranche")
            {
                Code = (int)CodeErreur.Ligne
            };
        }
        // on transfère la liste dans le tableau des tranches
        tranchesImpot = tranches.ToArray();
    }

    // conversion d'un champ en decimal >= 0, indépendamment de la culture de la machine
    private static bool Lire(string champ, out decimal valeur) =>
        decimal.TryParse(champ, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out valeur);
}

Commentaires :

  • ligne 7 : la classe [FileImpot] dérive de la classe [AbstractImpot], comme le faisait dans la version 2 la classe [HardwiredImpot]. Elle est déclarée [partial] parce qu'elle contient une expression régulière générée ;
  • lignes 10-11 : l'expression régulière du séparateur des champs d'une ligne limite:coeffR:coeffN : le caractère [:], précédé et suivi d'un nombre quelconque d'espaces ;
  • lignes 14-17 : les tranches lues, rendues par la propriété [TranchesImpot] qui redéfinit la propriété abstraite de [AbstractImpot] ;
  • ligne 19 : le constructeur reçoit le nom du fichier texte contenant les données. Son rôle est de remplir le tableau [tranchesImpot] ;
  • ligne 22 : le nom du fichier sans son chemin, utilisé dans les messages d'erreur. Le constructeur reçoit en effet un chemin complet, qu'il n'est pas utile d'afficher à l'utilisateur ;
  • ligne 24 : la lecture d'un fichier texte est séquentielle : on ne connaît le nombre de lignes qu'après avoir lu la totalité du fichier. On ne peut donc pas dimensionner le tableau à l'avance : on mémorise provisoirement les tranches dans une liste ;
  • ligne 28 : le fichier est lu ligne par ligne avec [File.ReadLines], qui ouvre et ferme le fichier lui-même. Le cours de 2008 utilisait un [StreamReader] ;
  • lignes 38-43 : la ligne est découpée en champs grâce à l'expression régulière ; s'il n'y a pas trois champs, une exception [ImpotException] de code [Ligne] est lancée. Le cours de 2008 mémorisait l'erreur dans une variable, sortait de la boucle, puis lançait l'exception après la fermeture du fichier : la lancer immédiatement est plus simple, et [File.ReadLines] ferme le fichier dans tous les cas, car la boucle [foreach] libère son énumérateur même lorsqu'une exception l'interrompt ;
  • lignes 47-55 : conversion des trois champs en nombres décimaux. Les erreurs sont cumulées dans la variable [code] avec l'opérateur [|=], puis signalées ensemble ;
  • lignes 83-84 : la conversion d'un champ. [NumberStyles.AllowDecimalPoint] n'autorise que des chiffres et un point décimal : ni signe, ni espaces, ni séparateur des milliers. Les nombres négatifs sont ainsi refusés, ce qui valide les données en même temps qu'on les convertit. [CultureInfo.InvariantCulture] impose le point comme séparateur décimal, quelle que soit la culture de la machine ;
  • lignes 62-67 : les erreurs d'accès au fichier ([IOException] et ses dérivées, comme [FileNotFoundException], ou [UnauthorizedAccessException] pour des droits insuffisants) sont encapsulées dans une [ImpotException] de code [Acces]. Le filtre d'exception [when (...)] (C# 6) n'intercepte que ces exceptions : les [ImpotException] lancées dans la boucle ne sont pas interceptées et sortent du constructeur telles quelles. Le motif [e is IOException or UnauthorizedAccessException] est un motif de type combiné par [or] (C# 9) ;
  • lignes 71-79 : un fichier vide est refusé ; sinon la liste est transférée dans le tableau des tranches, ce qui était le but du constructeur.

Encapsuler les exceptions de bas niveau ([IOException]) dans une exception propre à l'application ([ImpotException]) permet au code appelant de ne traiter qu'un seul type d'exception, quelle que soit la source des données : un fichier texte, un fichier JSON et, plus tard dans le cours, une base de données.

5.9.4. La classe JsonImpot

La classe [JsonImpot] fournit les tranches lues dans un fichier JSON :

using System.Text.Json;

namespace ImpotsV3;

// les tranches d'impôt lues dans un fichier JSON
public class JsonImpot : AbstractImpot
{
    // options « Web » : noms de propriétés en camelCase (limite, coeffR, coeffN)
    private static readonly JsonSerializerOptions options = new(JsonSerializerDefaults.Web);

    // les tranches d'impôt lues dans le fichier
    private readonly TrancheImpot[] tranchesImpot;

    // implémentation de la propriété abstraite de la classe parent
    protected override TrancheImpot[] TranchesImpot => tranchesImpot;

    public JsonImpot(string nomFichier)
    {
        // le nom du fichier, sans son chemin, pour les messages d'erreur
        string nom = Path.GetFileName(nomFichier);
        string json;
        try
        {
            // lecture du fichier
            json = File.ReadAllText(nomFichier);
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            throw new ImpotException($"Erreur lors de la lecture du fichier {nom}", e)
            {
                Code = (int)CodeErreur.Acces
            };
        }
        TrancheImpot[]? tranches;
        try
        {
            // désérialisation du tableau des tranches
            tranches = JsonSerializer.Deserialize<TrancheImpot[]>(json, options);
        }
        catch (JsonException e)
        {
            throw new ImpotException($"Le fichier {nom} est incorrect : {e.Message}", e)
            {
                Code = (int)CodeErreur.Json
            };
        }
        // il faut au moins une tranche, sans valeur négative
        if (tranches is null || tranches.Length == 0)
        {
            throw new ImpotException($"Le fichier {nom} ne contient aucune tranche")
            {
                Code = (int)CodeErreur.Json
            };
        }
        if (tranches.Any(t => t.Limite < 0 || t.CoeffR < 0 || t.CoeffN < 0))
        {
            throw new ImpotException($"Le fichier {nom} contient une valeur négative")
            {
                Code = (int)CodeErreur.Json
            };
        }
        tranchesImpot = tranches;
    }
}

Commentaires :

  • ligne 9 : les options « Web » : les propriétés JSON [limite], [coeffR], [coeffN] correspondent aux propriétés [Limite], [CoeffR], [CoeffN] de la structure [TrancheImpot] sans qu'il soit nécessaire de modifier cette dernière avec des attributs. Les options sont stockées dans un champ statique : un objet [JsonSerializerOptions] mémorise des informations sur les types qu'il a traités, et il est recommandé de le réutiliser ;
  • lignes 25-31 : la lecture du fichier ; une erreur d'accès est encapsulée comme dans [FileImpot] ;
  • ligne 38 : une seule instruction transforme le texte JSON en tableau de tranches. [System.Text.Json] sait construire une structure enregistrement en appelant son constructeur. Un document incorrect provoque une [JsonException], encapsulée dans une [ImpotException] de code [Json] ;
  • lignes 48-57 : la désérialisation vérifie le format du document, pas la validité des données : il reste à vérifier qu'il y a au moins une tranche (le document [null] ou [] est un JSON valide) et qu'aucune valeur n'est négative. La méthode LINQ [Any] fait ce dernier test en une ligne.

La comparaison des deux classes est instructive : avec un format de données structuré et une bibliothèque qui le connaît, l'analyse du fichier ne demande plus de code.

5.9.5. Le programme principal

Le projet console [ImpotsV3] a l'arborescence suivante :

05-classes-dotnet/
  05-classes-dotnet.slnx
  ImpotsV3/
    AbstractImpot.cs
    DataImpot.json
    DataImpot.txt
    DataImpotInvalide.json
    DataImpotInvalide.txt
    FileImpot.cs
    IImpot.cs
    ImpotException.cs
    ImpotsV3.csproj
    JsonImpot.cs
    Program.cs
    TrancheImpot.cs

Le fichier projet [ImpotsV3.csproj] est celui créé par [dotnet new console], auquel on a ajouté un groupe d'éléments :

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <!-- les fichiers de données sont copiés à côté de l'exécutable -->
  <ItemGroup>
    <None Update="DataImpot*.txt;DataImpot*.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

</Project>

L'élément [<None Update="..." CopyToOutputDirectory="PreserveNewest" />] (ligne 12) demande que les fichiers de données soient copiés dans le dossier de l'exécutable ([bin/Debug/net10.0]) lors de la compilation, s'ils sont plus récents que la copie existante. Le caractère [*] permet de désigner tous les fichiers [DataImpot*.txt] et [DataImpot*.json] en une fois. C'est l'équivalent de la propriété « Copy to Output Directory » que le cours de 2008 fixait dans les propriétés du fichier sous Visual Studio.

Le programme [Program.cs] est le suivant :

using ImpotsV3;

// calcul de l'impôt, version 3 : les tranches d'impôt sont lues dans un fichier
// syntaxe : dotnet run [-- fichier] ; fichier : DataImpot.txt par défaut, ou un fichier .json

const string syntaxe = """
    syntaxe : Marié NbEnfants Salaire
    Marié : o pour marié, n pour non marié
    NbEnfants : nombre d'enfants
    Salaire : salaire annuel en euros
    """;

// le fichier de données est cherché à côté de l'exécutable
string nomFichier = args.Length > 0 ? args[0] : "DataImpot.txt";
string chemin = Path.Combine(AppContext.BaseDirectory, nomFichier);
Console.WriteLine($"Tranches d'impôt lues dans [{nomFichier}]");

// création d'un objet IImpot ; la classe dépend de l'extension du fichier
IImpot impot;
try
{
    impot = Path.GetExtension(nomFichier).ToLower() switch
    {
        ".json" => new JsonImpot(chemin),
        _ => new FileImpot(chemin)
    };
}
catch (ImpotException e)
{
    // affichage de l'erreur ; le code est affiché sous la forme d'une combinaison de CodeErreur
    string origine = e.InnerException is null ? "" : $", Exception d'origine : {e.InnerException.GetType().Name}";
    Console.WriteLine($"L'erreur suivante s'est produite : [Code={(CodeErreur)e.Code}, Message={e.Message}{origine}]");
    // arrêt du programme
    return 1;
}

// boucle de saisie
while (true)
{
    // on demande les paramètres du calcul de l'impôt
    Console.Write("Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : ");
    string parametres = (Console.ReadLine() ?? "").Trim();
    // quelque chose à faire ?
    if (parametres == "")
    {
        break;
    }
    // vérification du nombre de champs de la ligne saisie
    string[] champs = parametres.Split(' ', StringSplitOptions.RemoveEmptyEntries);
    if (champs.Length != 3)
    {
        Console.WriteLine(syntaxe);
        continue;
    }
    // vérification de la validité des paramètres
    // marié
    string marie = champs[0].ToLower();
    if (marie != "o" && marie != "n")
    {
        Console.WriteLine(syntaxe + "\nArgument marié incorrect : tapez o ou n");
        continue;
    }
    // nbEnfants
    if (!int.TryParse(champs[1], out int nbEnfants) || nbEnfants < 0)
    {
        Console.WriteLine(syntaxe + "\nArgument NbEnfants incorrect : tapez un entier positif ou nul");
        continue;
    }
    // salaire
    if (!int.TryParse(champs[2], out int salaire) || salaire < 0)
    {
        Console.WriteLine(syntaxe + "\nArgument salaire incorrect : tapez un entier positif ou nul");
        continue;
    }
    // les paramètres sont corrects : on calcule l'impôt
    Console.WriteLine($"Impôt={impot.Calculer(marie == "o", nbEnfants, salaire)} euros");
}
return 0;

Commentaires :

  • ligne 14 : le nom du fichier de données est passé en argument ; par défaut, c'est [DataImpot.txt] ;
  • ligne 15 : le fichier est cherché dans le dossier de l'exécutable, [AppContext.BaseDirectory], où il a été copié lors de la compilation. Ainsi, le programme trouve ses données quel que soit le dossier d'où on le lance, ce qui n'était pas le cas des applications fichier unique de ce chapitre ;
  • lignes 22-25 : une expression [switch] (chapitre 3) choisit la classe à instancier selon l'extension du fichier. Les deux branches rendent des types différents, [JsonImpot] et [FileImpot] : le compilateur en déduit le type de l'expression grâce à la variable de destination [impot], de type [IImpot] (expression [switch] à type cible). Le reste du programme ne connaît que l'interface [IImpot] et ignore d'où viennent les données ;
  • lignes 28-34 : la création de l'objet peut lancer une [ImpotException], qui est affichée avant l'arrêt du programme. Le code est converti en [CodeErreur] pour être affiché en clair ; l'exception d'origine, s'il y en a une, est indiquée par son type (son message contient le chemin complet du fichier) ;
  • lignes 37-76 : la boucle de dialogue est exactement celle de la version 2.

5.9.6. Exécutions

Avec le fichier [DataImpot.txt], par défaut :

> dotnet run
Tranches d'impôt lues dans [DataImpot.txt]
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : q s d
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument marié incorrect : tapez o ou n
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 d
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Argument salaire incorrect : tapez un entier positif ou nul
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2
syntaxe : Marié NbEnfants Salaire
Marié : o pour marié, n pour non marié
NbEnfants : nombre d'enfants
Salaire : salaire annuel en euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : 

On retrouve les valeurs des versions précédentes : 4282 euros pour un couple marié avec 2 enfants, 2959 euros avec 3 enfants, 11519 euros pour une personne seule sans enfant, avec un salaire annuel de 60000 euros. Les saisies incorrectes sont signalées comme dans la version 2.

Avec le fichier [DataImpot.json] :

1
2
3
4
5
6
7
8
9
> dotnet run -- DataImpot.json
Tranches d'impôt lues dans [DataImpot.json]
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : 

Avec un fichier [xx.txt] inexistant :

1
2
3
> dotnet run -- xx.txt
Tranches d'impôt lues dans [xx.txt]
L'erreur suivante s'est produite : [Code=Acces, Message=Erreur lors de la lecture du fichier xx.txt, Exception d'origine : FileNotFoundException]

Avec le fichier [DataImpotInvalide.txt] suivant, dont la troisième ligne utilise la virgule décimale :

1
2
3
4
4962:0:0
8382:0.068:291.09
14753:0,191:1322,92
23888:0.283:2668.39
1
2
3
> dotnet run -- DataImpotInvalide.txt
Tranches d'impôt lues dans [DataImpotInvalide.txt]
L'erreur suivante s'est produite : [Code=Champ2, Champ3, Message=Ligne n° 3 incorrecte (14753:0,191:1322,92)]

Les champs 2 et 3 de la ligne 3 sont refusés : le code d'erreur est la combinaison « Champ2, Champ3 ». Le champ 1 (14753), sans partie décimale, est correct.

Avec le fichier [DataImpotInvalide.json] suivant, où un coefficient est écrit sous la forme d'une chaîne :

1
2
3
4
[
  { "limite": 4962, "coeffR": 0, "coeffN": 0 },
  { "limite": 8382, "coeffR": "0,068", "coeffN": 291.09 }
]
1
2
3
> dotnet run -- DataImpotInvalide.json
Tranches d'impôt lues dans [DataImpotInvalide.json]
L'erreur suivante s'est produite : [Code=Json, Message=Le fichier DataImpotInvalide.json est incorrect : The JSON value could not be converted to System.Decimal. Path: $[1].coeffR | LineNumber: 2 | BytePositionInLine: 37., Exception d'origine : JsonException]

Le message de la [JsonException] indique précisément l'emplacement de l'erreur : la propriété [coeffR] du deuxième élément du tableau ([$[1].coeffR]), [LineNumber: 2], c'est-à-dire la troisième ligne du document, car [System.Text.Json] numérote les lignes à partir de 0.

5.10. Conclusion

Ce chapitre a présenté les classes de .NET dont on se sert dans presque tous les programmes :

  • les chaînes [string], immuables, et [StringBuilder] pour les construire par morceaux ; l'interpolation avec largeurs et formats, les chaînes brutes ; les vues [ReadOnlySpan<char>] pour le code critique ;
  • les tableaux et la classe [Array] ;
  • les collections génériques [List<T>], [Dictionary<TKey,TValue>], [HashSet<T>], [Queue<T>], [Stack<T>], [PriorityQueue<TElement,TPriority>], et leurs interfaces en lecture seule ; les collections non génériques [ArrayList] et [Hashtable] sont obsolètes ;
  • LINQ, ses séquences [IEnumerable<T>], ses itérateurs et son exécution différée ;
  • les fichiers texte ([StreamReader], [StreamWriter], [File]), les fichiers binaires ([BinaryWriter], [BinaryReader]) et la sérialisation JSON, qui remplace la sérialisation binaire de [BinaryFormatter], supprimée de .NET ;
  • les expressions régulières, de préférence générées à la compilation et toujours protégées par un délai d'expiration lorsqu'elles traitent des données extérieures.

L'application exemple lit maintenant ses données dans un fichier texte ou JSON. Le chapitre suivant la réorganise en couches : la lecture des données, le calcul de l'impôt et le dialogue avec l'utilisateur y deviendront des composants indépendants.