Skip to content

5. Commonly Used .NET Classes

Here we present a few frequently used classes from the .NET platform: strings, arrays, collections, files, and regular expressions. We’ll also cover LINQ (Language Integrated Query), which the 2008 course omitted even though it had already been available since C# 3: it is now the most common way to manipulate collections in C#. Before that, we’ll show you how to find information about the thousands of available classes. This guide is indispensable for any C# developer, even experienced ones.

The examples in this chapter—with the exception of the sample application at the end—are single-file applications run by [dotnet run file.cs]. They are located in the [05-classes-dotnet] folder of the course code and run in the French (fr-FR) locale, except for the LINQ and regular-expression examples, which run in the American English locale (en-US).

5.1. Finding Help on .NET Classes

The 2008 course provided a detailed description, complete with screenshots, of the MSDN help installed with Visual Studio 2008. This local help is no longer available: the .NET documentation is now online, free of charge, and updated with each new version.

5.1.1. Online Documentation

The starting point is Microsoft’s learn.microsoft.com website. There you’ll find:

  • C# language documentation: https://learn.microsoft.com/en-us/dotnet/csharp/. It includes an overview of the language, tutorials, the language reference (keywords, operators, statements), and a “What’s New” section that describes each version of C#;
  • the .NET API reference: https://learn.microsoft.com/en-us/dotnet/api/. It describes, namespace by namespace, all classes in .NET: their constructors, properties, and methods, with examples;
  • the API browser, on the same page: a search box where you can type a class or method name ([StringBuilder], [Regex.Match]…). A drop-down list allows you to select the version of .NET (here, .NET 10).

The page for a class, such as System.String, displays:

  • the class’s namespace and the assembly that contains it;
  • its declaration, parent class, and the interfaces it implements;
  • a list of its constructors, properties, and methods, each with a link to a detailed page showing its various overloads, parameters, return values, possible exceptions, and often an example;
  • notes on its usage.

The pages are available in French (machine translation) and English (original text, which is sometimes more precise). You can switch between them by replacing [en-us] with [fr-fr] in the URL. A search on a search engine using the class’s full name (“System.Text.StringBuilder”) usually leads directly to the correct page.

5.1.2. Editor Help

The code editor (Visual Studio, Visual Studio Code with the C# Dev Kit extension, Rider) provides immediate help without having to leave the code:

  • autocompletion (IntelliSense): after typing [aString.], the editor suggests a list of the object’s properties and methods, along with a summary of each;
  • Tooltips: Hovering the mouse over a class, method, or variable name displays its declaration and a summary of its documentation;
  • Parameter help: When you type the opening parenthesis of a call, the editor displays the different overloads of the method and the role of each parameter;
  • Go to Definition (F12 key): Displays the declaration of the class or method. For a .NET class, the editor displays the public declarations reconstructed from the assembly, along with their documentation comments;
  • In Visual Studio, pressing the F1 key on a class or method name opens the corresponding page in the online documentation.

These documentation comments, which are visible when you hover over an element, are the ones used to generate the online reference. You can write similar comments in your own code using the [/// <summary> ... </summary>] syntax placed above a class or method: the editor will display them in the same way.

5.2. Strings

5.2.1. The System.String class

The C# type [string] is an alias for the [System.String] class. It has numerous properties and methods. Here are a few of them:

Member
role
[public int Length { get; }]
number of characters in the string
[public char this[int index] { get; }]
Character No. [index]: [aString[i]]
[public bool StartsWith(string value)]
returns [true] if the string begins with [value]
[public bool EndsWith(string value)]
returns [true] if the string ends with [value]
[public bool Contains(string value)]
returns [true] if the string contains [value]
[public bool Equals(string? value)]
returns [true] if the string is equal to [value]; equivalent to [aString == value]
[public int IndexOf(string value)]
the first occurrence of [value] in the string, or -1. Another overload starts the search at position [startIndex]
[public int IndexOf(char value)]
the same applies to the character [value]
[public int LastIndexOf(char value)]
same as [IndexOf] but returns the last position instead of the first
[public string Insert(int startIndex, string value)]
Inserts the string [value] into the string at position [startIndex]
[public static string Join(string separator, params string[] value)]
class method: concatenates the elements of [value], separating them with [separator]
[public string Replace(char oldChar, char newChar)]
returns a copy of the string where the character [oldChar] has been replaced by [newChar]; an overloaded method replaces one string with another
[public string[] Split(params char[] separator)]
the string is treated as a sequence of fields separated by the characters in [separator]; the result is an array of these fields
[public string Substring(int startIndex, int length)]
A substring of [length] starting at position [startIndex]
[public string ToLower()], [public string ToUpper()]
converts the string to lowercase, uppercase
[public string Trim()]
removes leading and trailing spaces from the string ([TrimStart], [TrimEnd]: on one side only)

It is important to note that a string of the type [string] is immutable; it cannot be modified after it is created. When a method returns a string, that string is different from the string to which the method was applied. Thus, [s1.Trim()] returns the string [s2], and [s1] and [s2] are two different strings: [s1] has not changed.

A string [c] can be considered a read-only array of characters:

  • [c[i]] is the i-th character of [c] (the first character is number 0);
  • [c.Length] is the number of characters in [c].

The program [strings-01.cs] is as follows:

// a few methods of the String class
string aString = "the bird flies above clouds";
Display("aString=" + aString);
Display("aString.Length=" + aString.Length);
Display("aString[10]=" + aString[10]);
Display("aString.IndexOf(\"flies\")=" + aString.IndexOf("flies"));
Display("aString.IndexOf(\"x\")=" + aString.IndexOf("x"));
Display("aString.LastIndexOf('a')=" + aString.LastIndexOf('a'));
Display("aString.LastIndexOf('x')=" + aString.LastIndexOf('x'));
Display("aString.Substring(4, 7)=" + aString.Substring(4, 7));
Display("aString.ToUpper()=" + aString.ToUpper());
Display("aString.ToLower()=" + aString.ToLower());
Display("aString.Replace('a', 'A')=" + aString.Replace('a', 'A'));
Display("aString.Insert(9, \"never \")=" + aString.Insert(9, "never "));
// splitting the string into words (separator: spaces)
string[] fields = aString.Split();
for (int i = 0; i < fields.Length; i++)
{
    Display("fields[" + i + "]=[" + fields[i] + "]");
}
Display("string.Join(\":\", fields)=" + string.Join(":", fields));
Display("\"  abc  \".Trim()=[" + "  abc  ".Trim() + "]");
// the original string has not been modified
Display("aString=" + aString);

// a local display method
static void Display(string msg)
{
    Console.WriteLine(msg);
}

Results:

> dotnet run strings-01.cs
aString=the bird flies above clouds
aString.Length=27
aString[10]=l
aString.IndexOf("flies")=9
aString.IndexOf("x")=-1
aString.LastIndexOf('a')=15
aString.LastIndexOf('x')=-1
aString.Substring(4, 7)=bird fl
aString.ToUpper()=THE BIRD FLIES ABOVE CLOUDS
aString.ToLower()=the bird flies above clouds
aString.Replace('a', 'A')=the bird flies Above clouds
aString.Insert(9, "never ")=the bird never flies above clouds
fields[0]=[the]
fields[1]=[bird]
fields[2]=[flies]
fields[3]=[above]
fields[4]=[clouds]
string.Join(":", fields)=the:bird:flies:above:clouds
"  abc  ".Trim()=[abc]
aString=the bird flies above clouds

Comments:

  • line 5: character #10, i.e., the 11th character in the string;
  • Lines 7, 9: An unsuccessful search returns -1;
  • line 10: the 7 characters starting at position 4;
  • line 14: an insertion at position 9, before the word “flies”;
  • line 16: [Split] without parameters splits the string at whitespace characters (space, tab, line break, etc.). The 2008 course used [Split(null)], which amounts to the same thing;
  • line 21: the static method [Join] does the opposite of [Split];
  • Line 23: All of the previous methods returned new strings: [aString] remained unchanged (line 21 of the results);
  • Lines 27–29: a local function, as in Chapter 3.

5.2.2. The Split and Join Methods

The program [strings-02.cs] delves deeper into splitting a string into fields:

// Split and Join
string line = "one:two::three:";
// splitting with the ':' separator
string[] fields = line.Split(':');
for (int i = 0; i < fields.Length; i++)
{
    Console.WriteLine($"fields[{i}]=[{fields[i]}]");
}
// the reverse operation
Console.WriteLine($"join=[{string.Join(":", fields)}]");

// the options of Split: removal of empty fields
fields = line.Split(':', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine($"RemoveEmptyEntries : {fields.Length} fields [{string.Join("|", fields)}]");

// several separators and removal of the spaces around the fields
string person = " dupont , jean ;  45 ";
fields = person.Split([',', ';'], StringSplitOptions.TrimEntries);
Console.WriteLine($"TrimEntries : {fields.Length} fields [{string.Join("|", fields)}]");

// both options combined
string list = "  red ,, green ,  , blue ";
fields = list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Console.WriteLine($"both options: {fields.Length} fields [{string.Join("|", fields)}]");

// a separator that is a string
string text = "apple -> pear -> apricot";
fields = text.Split(" -> ");
Console.WriteLine($"string separator: [{string.Join("|", fields)}]");

// Join accepts any collection, not only strings
int[] numbers = [1, 2, 3, 4];
Console.WriteLine($"Join on integers: {string.Join(" + ", numbers)}");

Results:

> dotnet run strings-02.cs
fields[0]=[one]
fields[1]=[two]
fields[2]=[]
fields[3]=[three]
fields[4]=[]
join=[one:two::three:]
RemoveEmptyEntries : 3 fields [one|two|three]
TrimEntries : 3 fields [dupont|jean|45]
both options: 3 fields [red|green|blue]
string separator: [apple|pear|apricot]
Join on integers: 1 + 2 + 3 + 4

Comments:

  • Line 4: The fields are separated by the character [:]. Two consecutive [:] characters, or a single [:] at the end of the string, delimit an empty field: this results in 5 fields, 2 of which are empty (lines 4 and 6 of the results). [Join] reconstructs the original string;
  • the [Split] method has numerous overloads. The 2008 version, [Split(char[] separator)], is now written as [Split(params char[] separator)]: the keyword [params] allows you to process the separators one by one—[Split(':')] or [Split(',', ';')]—without creating an array;
  • Line 13: The enumeration [StringSplitOptions] modifies the field breakdown. [RemoveEmptyEntries] removes empty fields;
  • line 18: [TrimEntries] (.NET 5) removes spaces around each field. The separators are passed here as a collection expression [',', ';'];
  • line 23: the two options are combined using the [|] operator. Spaces are removed before the empty-field check: the field consisting of two spaces between two commas is removed;
  • line 28: the separator can be a string consisting of multiple characters;
  • line 33: [Join] accepts a collection of elements of any type; each element is converted to a string using its [ToString] method.

5.2.3. Searching for and Comparing Strings

String comparison is more subtle than it seems: should uppercase and lowercase letters be distinguished? How should “éclair” be ranked relative to “essay”? The program [strings-03.cs] presents the search and comparison methods:

// searching and comparing strings
string title = "Learning the C# language with .NET";

// Contains, StartsWith, EndsWith: exact comparison by default
Console.WriteLine($"Contains(\"language\") : {title.Contains("language")}");
Console.WriteLine($"Contains(\"LANGUAGE\") : {title.Contains("LANGUAGE")}");
Console.WriteLine($"StartsWith(\"Learning\") : {title.StartsWith("Learning")}");
Console.WriteLine($"EndsWith('T') : {title.EndsWith('T')}");

// the comparison mode is specified with the StringComparison enumeration
Console.WriteLine($"Contains(\"LANGUAGE\", OrdinalIgnoreCase) : {title.Contains("LANGUAGE", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"StartsWith(\"learning\", OrdinalIgnoreCase) : {title.StartsWith("learning", StringComparison.OrdinalIgnoreCase)}");

// equality: the == operator compares the contents of the strings
string answer = "YES";
Console.WriteLine($"answer == \"yes\" : {answer == "yes"}");
Console.WriteLine($"string.Equals(answer, \"yes\", OrdinalIgnoreCase) : {string.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)}");

// alphabetical order: comparison according to the culture or to the character codes
string a = "éclair", b = "essay";
Console.WriteLine($"Compare(\"{a}\", \"{b}\", CurrentCulture) : {string.Compare(a, b, StringComparison.CurrentCulture)}");
Console.WriteLine($"Compare(\"{a}\", \"{b}\", Ordinal) : {string.Compare(a, b, StringComparison.Ordinal)}");

// empty or blank strings
string? empty = null;
string blank = "   ";
Console.WriteLine($"IsNullOrEmpty(null) : {string.IsNullOrEmpty(empty)}");
Console.WriteLine($"IsNullOrEmpty(\"   \") : {string.IsNullOrEmpty(blank)}");
Console.WriteLine($"IsNullOrWhiteSpace(\"   \") : {string.IsNullOrWhiteSpace(blank)}");

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

Results:

> dotnet run strings-03.cs
Contains("language") : True
Contains("LANGUAGE") : False
StartsWith("Learning") : True
EndsWith('T') : True
Contains("LANGUAGE", OrdinalIgnoreCase) : True
StartsWith("learning", OrdinalIgnoreCase) : True
answer == "yes" : False
string.Equals(answer, "yes", OrdinalIgnoreCase) : True
Compare("éclair", "essay", CurrentCulture) : -1
Compare("éclair", "essay", Ordinal) : 132
IsNullOrEmpty(null) : True
IsNullOrEmpty("   ") : False
IsNullOrWhiteSpace("   ") : True
[     abc] [abc.....] [--------]

Comments:

  • lines 5–8: [Contains], [StartsWith], and [EndsWith] perform an exact, character-by-character comparison by default: “LANGUAGE” is not found;
  • Lines 11–12: A parameter of the type [StringComparison] specifies the comparison mode. The most useful values are:
    • [Ordinal]: comparison of character codes;
    • [OrdinalIgnoreCase]: same as above, ignoring case;
    • [CurrentCulture], [CurrentCultureIgnoreCase]: comparison according to the rules of the machine’s language;
    • [InvariantCulture]: comparison based on machine-independent rules;
  • line 16: the operator [] compares the contents of the strings, just like the method [Equals]. This is an important difference from Java, where [] compares references;
  • line 17: the static form of [Equals] also accepts a [StringComparison]. This is the correct way to test a “yes” response entered from the keyboard in uppercase or lowercase;
  • Lines 21–22: [string.Compare] returns a negative, zero, or positive integer depending on whether the first string comes before, is equal to, or comes after the second. In French, “éclair” comes before “essay” (the letter “é” is classified with “e,” line 10 of the results). In ordinal comparison, “éclair” comes after “essay” because the code for the first character, “é” (233), is greater than that of the character “e” (101): the ordinal comparison here returns the difference between the two codes, 132. We will use the cultural order to sort words intended for a user and the ordinal order to compare technical identifiers (file names, keys, etc.);
  • lines 27–29: two static methods for testing a string that is missing ([null]), empty, or contains only spaces;
  • Line 32: [PadLeft] and [PadRight] pad a string to a given length; the constructor [new string('-', 8)] repeats a character.

5.2.4. Interpolation and Formats

String interpolation [$"..."] (C# 6) has been used since the beginning of this course. Like the [string.Format] method, it accepts a width and a format for each expression: [{expression,width:format}]. The program [strings-04.cs] provides examples of this, as well as raw C# 11 strings:

// interpolation, formats and raw strings
string name = "dupont";
int age = 45;
decimal salary = 2345.678m;
double rate = 0.0525;
DateTime date = new(2026, 10, 3, 14, 5, 9);

// simple interpolation
Console.WriteLine($"{name} is {age} years old");
// {expression,width:format}: width > 0 aligns right, < 0 aligns left
Console.WriteLine($"[{name,10}] [{name,-10}] [{age,5}]");
Console.WriteLine($"[{salary,12:F2}] [{salary,-12:F1}] [{salary:N2}] [{salary:C}]");
Console.WriteLine($"[{rate:P2}] [{age:D5}] [{age:X}] [{1234567:N0}]");
Console.WriteLine($"[{date:dd/MM/yyyy}] [{date:HH:mm:ss}] [{date:dddd d MMMM yyyy}]");
// any expression, here a conditional expression in parentheses
Console.WriteLine($"{name} is {(age >= 18 ? "an adult" : "a minor")}");

// the string.Format method and the {n} notation of previous versions of C#
Console.WriteLine(string.Format("{0} is {1} years old and earns {2:F2} euros", name, age, salary));

// an aligned table
string[] products = ["apple", "pear", "apricot"];
decimal[] prices = [2.5m, 3.15m, 12m];
for (int i = 0; i < products.Length; i++)
{
    Console.WriteLine($"{products[i],-10}|{prices[i],8:F2} €");
}

// verbatim string: \ is not an escape character
string path = @"C:\data\course\infos.txt";
Console.WriteLine(path);

// raw string (C# 11): delimited by at least three double quotes, over several lines
string json = """
    {
      "name": "dupont",
      "path": "C:\data"
    }
    """;
Console.WriteLine(json);
// interpolated raw string: with $$, single braces remain literal
string json2 = $$"""{ "name": "{{name}}", "age": {{age}} }""";
Console.WriteLine(json2);

Results:

> dotnet run strings-04.cs
dupont is 45 years old
[    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 is an adult
dupont is 45 years old and earns 2345,68 euros
apple     |    2,50 €
pear      |    3,15 €
apricot   |   12,00 €
C:\data\course\infos.txt
{
  "name": "dupont",
  "path": "C:\data"
}
{ "name": "dupont", "age": 45 }

Comments:

  • line 11: a positive width aligns the value to the right within a 10-character field; a negative width aligns it to the left;
  • Lines 12–14: The most common formats:
format
Meaning
example (fr-FR)
[F2]
fixed-point number, 2 decimal places
2345,68
[N2]
same as above, with a thousands separator
2 345,68
[C]
monetary amount for the current culture
2 345,68 €
[P2]
percentage (the value is multiplied by 100)
5,25 %
[D5]
5-digit integer or more, padded with zeros
00045
[X]
hexadecimal integer
2D
[dd/MM/yyyy], [HH:mm:ss]
date and time (MM: month, mm: minutes)
03/10/2026, 14:05:09
[dddd d MMMM yyyy]
day and month spelled out
samedi 3 octobre 2026
  • Numbers and dates are formatted according to the current locale, in this case fr-FR: decimal comma, space as a thousands separator, day and month names in French;
  • line 16: a conditional expression placed within an interpolation must be enclosed in parentheses, because the character [:] would introduce a format there;
  • line 19: the notation [{0}], [{1}]… for [string.Format] is the one used in the 2008 course (and [Console.WriteLine], which accepts the same parameters). It is still commonly encountered; the interpolation is more readable;
  • lines 24–26: the widths allow data to be displayed in columns;
  • line 30: a verbatim string [@"..."] does not treat [\] as an escape character. This is useful for Windows paths and regular expressions;
  • lines 34–39: A raw string (C# 11) is delimited by at least three quotation marks. It can span multiple lines and contain unescaped quotation marks and [\]. The indentation of the closing quote is removed from each line: the text JSON is displayed without the 4 spaces from the code margin;
  • Line 42: A raw string can be interpolated. With [$$], an interpolated expression is written between double curly braces [{{name}}], and single curly braces—which are common in a text like JSON—remain ordinary characters.

5.2.5. The System.Text.StringBuilder class

We mentioned that the methods of the [String] class that operate on a string [s1] return another string [s2]. The [System.Text.StringBuilder] class allows you to modify a string without creating a new string for each operation. This improves performance when constructing a string through many successive additions, by avoiding the proliferation of strings with very short lifespans.

The class supports various constructors:

constructor
role
[StringBuilder()]
default constructor
[StringBuilder(string value)]
Build and initialize with [value]
[StringBuilder(string value, int capacity)]
Construction and initialization with [value], with an initial capacity of [capacity] characters

A [StringBuilder] object stores its characters in a memory area whose size—its capacity—is automatically adjusted as the text grows. By default, the initial capacity is 16 characters. The third constructor allows you to specify it. The program [strings-05.cs] illustrates this concept of capacity:

using System.Text;

// size and capacity of a StringBuilder
var str = new StringBuilder("test");
Console.WriteLine($"size={str.Length}, capacity={str.Capacity}");
for (int i = 0; i < 10; i++)
{
    str.Append("test");
    Console.WriteLine($"size={str.Length}, capacity={str.Capacity}");
}
// a StringBuilder with initial capacity 10
var str2 = new StringBuilder("test", 10);
Console.WriteLine($"size={str2.Length}, capacity={str2.Capacity}");
for (int i = 0; i < 10; i++)
{
    str2.Append("test");
    Console.WriteLine($"size={str2.Length}, capacity={str2.Capacity}");
}

Results:

> dotnet run strings-05.cs
size=4, capacity=16
size=8, capacity=16
size=12, capacity=16
size=16, capacity=16
size=20, capacity=32
size=24, capacity=32
size=28, capacity=32
size=32, capacity=32
size=36, capacity=64
size=40, capacity=64
size=44, capacity=64
size=4, capacity=10
size=8, capacity=10
size=12, capacity=20
size=16, capacity=20
size=20, capacity=20
size=24, capacity=40
size=28, capacity=40
size=32, capacity=40
size=36, capacity=40
size=40, capacity=40
size=44, capacity=80

Comments:

  • line 4: creation of a [StringBuilder] object with an initial capacity of 16;
  • line 5: [Length] is the current number of characters; [Capacity] is the number of characters the object can hold before it needs to expand its memory area;
  • line 8: [Append] adds a string to the end of the text;
  • line 12: creation of an object [StringBuilder] with an initial capacity of 10.

The results show that the capacity doubles when it is insufficient: 16, 32, 64 (lines 6 and 10 of the results), and 10, 20, 40, 80 for the second object. These are the same results as in 2008. If the approximate size of the text is known in advance, setting it at the time of construction avoids successive enlargements.

Here are some of the class’s methods:

method
role
[StringBuilder Append(string value)]
Adds [value] to the end of the text. Overridden to accept the types [int], [double], [decimal], [char]…, and an interpolated string
[StringBuilder AppendLine()]
adds a line break (an overloaded function adds a string followed by a line break)
[StringBuilder AppendJoin(string separator, params string[] values)]
adds the elements of [values] separated by [separator]
[StringBuilder Insert(int index, string value)]
inserts [value] at the position of [index]; overridden as [Append]
[StringBuilder Remove(int index, int length)]
deletes [length] characters starting at position [index]
[StringBuilder Replace(string oldValue, string newValue)]
replaces [oldValue] with [newValue]; an override replaces a character
[string ToString()]
converts the text into a string [string]

The program [strings-06.cs] uses them:

using System.Text;

// the methods of StringBuilder return the StringBuilder object itself: they can be chained
var str3 = new StringBuilder("test");
Console.WriteLine(str3.Append("abCD").Insert(2, "xyZT").Remove(0, 2).Replace("xy", "XY"));

// building a string piece by piece
var lines = new StringBuilder();
for (int i = 1; i <= 3; i++)
{
    lines.Append("line ").Append(i).AppendLine();
}
// AppendJoin: the equivalent of string.Join
lines.AppendJoin(", ", "a", "b", "c").AppendLine();
// Append also accepts an interpolated string
int total = 42;
lines.Append($"total={total,6:F1}");
// the final string
string result = lines.ToString();
Console.WriteLine(result);
Console.WriteLine($"{result.Length} characters");

Results:

1
2
3
4
5
6
7
8
> dotnet run strings-06.cs
XYZTstabCD
line 1
line 2
line 3
a, b, c
total=  42,0
41 characters

Comments:

  • Line 5: The modification methods return the [StringBuilder] object itself, which allows them to be chained together. Let’s follow the steps: “test” becomes “testabCD” ([Append]), then “texyZTstabCD” ([Insert] in position 2), then “xyZTstabCD” ([Remove] from the first 2 characters), and finally “XYZTstabCD” ([Replace]). [Console.WriteLine] displays the object by calling its method [ToString];
  • lines 9–11: constructing a multi-line text through successive additions: this is the typical use of [StringBuilder];
  • Line 17: [Append] accepts an interpolated string, including width and format;
  • Line 19: The final text is obtained as a [string] string.

For a few concatenations, the [+] operator or interpolation is sufficient: the compiler optimizes them. [StringBuilder] is necessary when the number of additions is large or unknown, typically in a loop.

5.2.6. Views on a string: Span<char>

.NET Core 2.1 (2018) introduced the types [Span<T>] and [ReadOnlySpan<T>]. A [ReadOnlySpan<char>] object is a view into an existing sequence of characters, such as a portion of a string: it does not contain a copy of the characters, only a way to access them. Extracting a portion of a string using a view therefore does not create a new string. The program [strings-07.cs] compares the two ways of parsing a line in the format “last name:first name:age”:

// ReadOnlySpan<char>: a "view" on a part of a string, without copying
string line = "dupont:jean:45";

// with Substring: each field is a new string allocated in memory
int p1 = line.IndexOf(':');
int p2 = line.LastIndexOf(':');
string lastName = line.Substring(0, p1);
int age = int.Parse(line.Substring(p2 + 1));
Console.WriteLine($"Substring : last name={lastName}, age={age}");

// with a view: no intermediate string is created
ReadOnlySpan<char> view = line.AsSpan();
ReadOnlySpan<char> lastNameView = view[..p1];     // characters 0 to p1-1
ReadOnlySpan<char> ageView = view[(p2 + 1)..];    // from p2+1 to the end
// int.Parse accepts a view
age = int.Parse(ageView);
Console.WriteLine($"Span : last name={lastNameView}, age={age}, length of the view={lastNameView.Length}");
// a view can be compared to a string without copying it
Console.WriteLine($"the last name starts with \"du\": {lastNameView.StartsWith("du")}");
// a string is created only if it is really needed
string firstName = view[(p1 + 1)..p2].ToString();
Console.WriteLine($"first name={firstName}");

Results:

1
2
3
4
5
> dotnet run strings-07.cs
Substring : last name=dupont, age=45
Span : last name=dupont, age=45, length of the view=6
the last name starts with "du": True
first name=jean

Comments:

  • lines 7–8: Each call to [Substring] creates a new string in memory, even if, as with the age, it is only used for a moment;
  • line 12: [AsSpan] returns a view of the entire string;
  • Lines 13–14: The [start..end] ranges (C# 8), introduced in Chapter 3 for arrays, apply to views: two new views are obtained, without copying;
  • line 16: Many methods in .NET accept a view instead of a string ([Parse], [TryParse], [StartsWith], [Contains], [Equals]…);
  • Line 21: A string is created from a view only if it is actually needed.

Views are an optimization tool used extensively within .NET itself: this is one of the reasons why recent versions are significantly faster than the 2008 .NET Framework. A view can only exist on the execution stack: it cannot, for example, be a field of a class. For an ordinary program, [string] and [Substring] are perfectly suitable: views should only be used in code that has been identified as performance-critical. We will not go any further.

5.3. Arrays

5.3.1. The Array Class

Arrays are derived from the [System.Array] class. This class provides various methods for sorting an array, searching for an element in an array, resizing an array, and more. We’ll present some of its properties and methods. Almost all of them are overloaded, meaning they exist in different variants. Every array inherits these.

Properties:

property
role
[public int Length { get; }]
total number of elements in the array, regardless of the number of dimensions
[public int Rank { get; }]
Number of dimensions in the array

Methods:

Method
role
[public static int BinarySearch<T>(T[] array, T value)]
Returns the position of [value] in [array], which must be sorted; a negative number if [value] is missing
[public static void Clear(Array array, int index, int length)]
sets the [length] elements of [array] starting at index [index] to their default values: 0 for numbers, [false] for booleans, [null] for references
[public static void Copy(Array source, Array destination, int length)]
copies the first [length] elements from [source] to [destination]
[public static void Fill<T>(T[] array, T value)]
assigns [value] to all elements of [array]
[public int GetLength(int dimension)]
Number of elements in dimension No. [dimension] of the table
[public static int IndexOf<T>(T[] array, T value)]
returns the position of [value] within [array], or -1 if [value] is not found; [LastIndexOf] returns the last position
[public static void Resize<T>(ref T[]? array, int newSize)]
resizes [array] to [newSize] elements; elements already present are retained
[public static void Reverse(Array array)]
reverses the order of the elements in [array]
[public static void Sort<T>(T[] array)]
sorts [array] according to the natural order of its elements; an overloaded method takes a [IComparer<T>] object that defines the order (Chapter 4)

The program [arrays-01.cs] illustrates the use of certain methods of the [Array] class:

// the Array class: resizing, sorting, searching

// reading the elements of an array typed on the keyboard
double[] elements = Input();
// display of the unsorted array
Display("Unsorted array", elements);
// linear search in the unsorted array
Search(elements, SearchType.Linear);
// sorting the array
Array.Sort(elements);
// display of the sorted array
Display("Sorted array", elements);
// binary search in the sorted array
Search(elements, SearchType.Binary);

// input of the array values: returns the created array
static double[] Input()
{
    // at the start, the array is empty
    double[] elements = [];
    // input loop for the array elements
    while (true)
    {
        Console.Write($"Element (real) {elements.Length} of the array (nothing to finish): ");
        string answer = (Console.ReadLine() ?? "").Trim();
        // end of input if empty string
        if (answer == "")
        {
            break;
        }
        // checking the input
        if (!double.TryParse(answer, out double element))
        {
            Console.WriteLine("Incorrect input, try again");
            continue;
        }
        // one more element: we enlarge the array by one cell
        Array.Resize(ref elements, elements.Length + 1);
        // and we store the new element in it
        elements[^1] = element;
    }
    return elements;
}

// generic method to display the elements of an array
static void Display<T>(string text, T[] elements)
{
    Console.WriteLine(text.PadRight(50, '-'));
    foreach (T element in elements)
    {
        Console.WriteLine(element);
    }
}

// search in the array for elements typed on the keyboard
static void Search(double[] elements, SearchType type)
{
    while (true)
    {
        Console.Write("Element searched for (nothing to stop): ");
        string answer = (Console.ReadLine() ?? "").Trim();
        // finished?
        if (answer == "")
        {
            break;
        }
        // checking
        if (!double.TryParse(answer, out double element))
        {
            Console.WriteLine("Error, try again...");
            continue;
        }
        // we search for the element in the array
        int i = type == SearchType.Binary
            ? Array.BinarySearch(elements, element)    // sorted array
            : Array.IndexOf(elements, element);        // any array
        // display of the answer
        Console.WriteLine(i >= 0 ? $"Found at position {i}" : $"Not in the array ({i})");
    }
}

// type of search
enum SearchType { Linear, Binary }

Comments:

  • Lines 17–42: The method [Input] enters the elements of an array typed from the keyboard. Since the number of elements is not known in advance, the program starts with an empty array (line 20) and expands it by one element for each new entry (line 38). [Resize] actually creates a new array, copies the elements from the old one into it, and stores its reference in the variable passed by [ref]. A more efficient algorithm would allocate space in groups of N elements. An array is not designed to be resized: this scenario is better handled with a list [List<T>], as we will see shortly;
  • line 40: [^1] refers to the last element of the array (indexed from the end, C# 8);
  • In the 2008 course, [Input] was a method with a parameter [out]; the modern version returns the created array, which is more natural;
  • Lines 56–78: The method [Search] searches the array for elements entered from the keyboard. For an unsorted array, a linear search is performed using [IndexOf] (line 76), which examines the elements one by one. For a sorted array, a binary search is performed using [BinarySearch] (line 75), which halves the search range at each step: it finds an element among one million in about twenty comparisons;
  • line 10: the array is sorted. Here, we use a variant of [Sort] that has only one parameter: the array to be sorted. The ordering relation used to compare the array elements is then the implicit one for those elements: in this case, the natural order of numbers;
  • lines 46–51: a generic method that displays an array of elements of any type, [T].

Results:

> dotnet run arrays-01.cs
Element (real) 0 of the array (nothing to finish): 3,6
Element (real) 1 of the array (nothing to finish): 7,4
Element (real) 2 of the array (nothing to finish): -1,5
Element (real) 3 of the array (nothing to finish): x
Incorrect input, try again
Element (real) 3 of the array (nothing to finish): -7
Element (real) 4 of the array (nothing to finish): 
Unsorted array------------------------------------
3,6
7,4
-1,5
-7
Element searched for (nothing to stop): 7,4
Found at position 1
Element searched for (nothing to stop): 0
Not in the array (-1)
Element searched for (nothing to stop): 
Sorted array--------------------------------------
-7
-1,5
3,6
7,4
Element searched for (nothing to stop): 7,4
Found at position 3
Element searched for (nothing to stop): 0
Not in the array (-3)
Element searched for (nothing to stop): 

When the element being searched for is absent, [IndexOf] returns -1 and [BinarySearch] returns a negative number (line 27): this is the one’s complement ([~i]) of the position where the element should be inserted to keep the array sorted. Here, [~(-3)] equals 2: the number 0 should be inserted at position 2, between -1.5 and 3.6. Note: [BinarySearch] on an unsorted array returns a meaningless result.

5.3.2. Other methods of the Array class

The program [arrays-02.cs] demonstrates other methods of the [Array] class:

// other methods of the Array class
int[] numbers = [40, 10, 30, 20, 50];
Display("numbers", numbers);

// Reverse: reverses the order of the elements, in place
Array.Reverse(numbers);
Display("after Reverse", numbers);

// Sort on a part of the array: 3 elements starting at index 1
Array.Sort(numbers, 1, 3);
Display("after Sort(numbers, 1, 3)", numbers);

// Copy: copy of 3 elements into another array
int[] copy = new int[5];
Array.Copy(numbers, copy, 3);
Display("copy", copy);

// Fill: fills the array with a value; Clear: resets the elements to their default value
Array.Fill(copy, 7);
Display("after Fill(copy, 7)", copy);
Array.Clear(copy, 1, 2);
Display("after Clear(copy, 1, 2)", copy);

// IndexOf, LastIndexOf on an array of strings
string[] colors = ["red", "green", "blue", "green"];
Console.WriteLine($"IndexOf(green)={Array.IndexOf(colors, "green")}, LastIndexOf(green)={Array.LastIndexOf(colors, "green")}, IndexOf(black)={Array.IndexOf(colors, "black")}");

// sorting an array of strings: alphabetical order of the current culture
Array.Sort(colors);
Display("sorted colors", colors);

// a two-dimensional array: Rank, Length, GetLength
int[,] matrix = new int[2, 3];
Console.WriteLine($"Rank={matrix.Rank}, Length={matrix.Length}, GetLength(0)={matrix.GetLength(0)}, GetLength(1)={matrix.GetLength(1)}");

// display of an array on one line
static void Display<T>(string text, T[] elements)
{
    Console.WriteLine($"{text,-28}: [{string.Join(", ", elements)}]");
}

Results:

> dotnet run arrays-02.cs
numbers                     : [40, 10, 30, 20, 50]
after Reverse               : [50, 20, 30, 10, 40]
after Sort(numbers, 1, 3)   : [50, 10, 20, 30, 40]
copy                        : [50, 10, 20, 0, 0]
after Fill(copy, 7)         : [7, 7, 7, 7, 7]
after Clear(copy, 1, 2)     : [7, 0, 0, 7, 7]
IndexOf(green)=1, LastIndexOf(green)=3, IndexOf(black)=-1
sorted colors               : [blue, green, green, red]
Rank=2, Length=6, GetLength(0)=2, GetLength(1)=3

Comments:

  • line 6: [Reverse] reverses the order of the elements in the array itself;
  • line 10: only 3 elements are sorted starting from position 1: 20, 30, 10 become 10, 20, 30;
  • line 15: copies the first 3 elements of [numbers] into [copy], while the other elements retain their initial value of 0;
  • Lines 19–21: [Fill] fills the array; [Clear] resets 2 elements to 0 starting at position 1;
  • line 29: the strings are sorted in alphabetical order according to the current culture;
  • Line 34: For a two-dimensional array, [Rank] is 2, [Length] is the total number of elements, and [GetLength(i)] is the number of elements in dimension i;
  • Line 39: [string.Join] displays an array on a single line.

LINQ, which we will discuss later, offers other ways to search, sort, or transform the elements of an array.

5.4. Generic Collections

In addition to arrays, there are various classes for storing collections of elements. Generic collections, introduced with .NET 2.0, are located in the [System.Collections.Generic] namespace, which is part of the implicit [using] namespaces. We will first introduce two frequently used collections—the list and the dictionary—and then briefly cover others.

The [System.Collections] namespace contains the older, non-generic versions of these collections, dating back to .NET 1.0: [ArrayList] (a list), [Hashtable] (a dictionary)... The 2008 course still featured [ArrayList]. These classes are now obsolete: they store references of type [object], which requires casting the read elements and boxing the numbers into objects, with associated memory and time costs. Generic collections have none of these drawbacks: a *[List<int>] stores its integers directly in an internal *[int[]] array, and the compiler checks the type of the elements being added. [ArrayList] and [Hashtable] should no longer be used in new code.

5.4.1. The generic class List<T>

The [System.Collections.Generic.List<T>] class allows you to implement collections of [T] objects whose size varies during program execution. An object of type [List<T>] is handled almost like an array. Thus, element i of a [l] list is denoted as [l[i]].

For a list of type [List<T>] where [T] is a class, the list stores references to objects of type [T], like an array of objects; if [T] is a value type ([int], [double], structure…), the values themselves are stored in the list.

Here are some of the properties and methods of generic lists.

Properties:

property
role
[public int Count { get; }]
number of elements in the list
[public int Capacity { get; set; }]
Number of elements the list can contain before it is expanded. This expansion occurs automatically. This concept of capacity is analogous to that described for class [StringBuilder]

Methods:

Method
role
[public void Add(T item)]
adds [item] to the end of the list
[public void AddRange(IEnumerable<T> collection)]
adds the elements of [collection] to the end of the list
[public int BinarySearch(T item)]
returns the position of [item] in the sorted list if it is present; otherwise, returns a negative number
[public void Clear()]
removes all elements from the list
[public bool Contains(T item)]
returns [true] if [item] is in the list, otherwise [false]
[public List<T> GetRange(int index, int count)]
returns a new list consisting of [count] elements starting from position [index]
[public int IndexOf(T item)]
Returns the position of [item] in the list, or -1 if [item] is not found
[public void Insert(int index, T item)]
inserts [item] at position [index] in the list
[public bool Remove(T item)]
removes the first occurrence of [item] from the list; returns [true] if the operation succeeds, [false] otherwise
[public void RemoveAt(int index)]
removes item No. [index] from the list
[public void Reverse()]
reverses the order of the items in the list
[public void Sort()]
sorts the list in natural order; an overloaded version takes a [IComparer<T>]
[public T[] ToArray()]
returns the list elements as an array

Let’s revisit the example we covered earlier with an array and now process it using an object of type [List<double>]. Because the list is similar to an array, the code changes very little. This is the program [collections-01.cs]:

// the List<T> class: the program arrays-01.cs rewritten with a list

// reading the elements of a list typed on the keyboard
List<double> elements = Input();
// number of elements and capacity
Console.WriteLine($"The list has {elements.Count} elements and a capacity of {elements.Capacity} elements");
// display of the unsorted list
Display("Unsorted list", elements);
// linear search in the unsorted list
Search(elements, SearchType.Linear);
// sorting the list
elements.Sort();
// display of the sorted list
Display("Sorted list", elements);
// binary search in the sorted list
Search(elements, SearchType.Binary);

// input of the list values: returns the created list
static List<double> Input()
{
    // at the start, the list is empty
    List<double> elements = new();
    // input loop for the list elements
    while (true)
    {
        Console.Write($"Element (real) {elements.Count} of the list (nothing to finish): ");
        string answer = (Console.ReadLine() ?? "").Trim();
        // end of input if empty string
        if (answer == "")
        {
            break;
        }
        // checking the input
        if (!double.TryParse(answer, out double element))
        {
            Console.WriteLine("Incorrect input, try again");
            continue;
        }
        // one more element in the list: it grows by itself
        elements.Add(element);
    }
    return elements;
}

// generic method to display the elements of any sequence
static void Display<T>(string text, IEnumerable<T> elements)
{
    Console.WriteLine(text.PadRight(50, '-'));
    foreach (T element in elements)
    {
        Console.WriteLine(element);
    }
}

// search in the list for elements typed on the keyboard
static void Search(List<double> elements, SearchType type)
{
    while (true)
    {
        Console.Write("Element searched for (nothing to stop): ");
        string answer = (Console.ReadLine() ?? "").Trim();
        // finished?
        if (answer == "")
        {
            break;
        }
        // checking
        if (!double.TryParse(answer, out double element))
        {
            Console.WriteLine("Error, try again...");
            continue;
        }
        // we search for the element in the list
        int i = type == SearchType.Binary
            ? elements.BinarySearch(element)    // sorted list
            : elements.IndexOf(element);        // any list
        // display of the answer
        Console.WriteLine(i >= 0 ? $"Found at position {i}" : $"Not in the list ({i})");
    }
}

// type of search
enum SearchType { Linear, Binary }

Comments:

  • line 22: the list is created empty. [new()] (C# 9) is a shorthand form of [new List<double>()]: the type is inferred from the variable declaration;
  • line 40: there is no longer any resizing to handle; the list expands automatically;
  • line 6: the number of elements in the list and its capacity;
  • lines 12, 75, 76: the methods [Sort], [BinarySearch], and [IndexOf] are now instance methods of the list, whereas those of [Array] are static methods;
  • lines 46–51: the generic method [Display<T>] takes two parameters:
    • the first is text to be written;
    • the second is an object that implements the generic interface [IEnumerable<T>]:
1
2
3
4
public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

The structure [foreach (T element in elements)] is valid for any [elements] object that implements the [IEnumerable<T>] interface. Arrays and lists implement this interface. Therefore, the [Display] method is suitable for displaying both arrays and lists. We will return to this interface, which is at the heart of LINQ.

Results:

> dotnet run collections-01.cs
Element (real) 0 of the list (nothing to finish): 3,6
Element (real) 1 of the list (nothing to finish): 7,4
Element (real) 2 of the list (nothing to finish): -1,5
Element (real) 3 of the list (nothing to finish): -7
Element (real) 4 of the list (nothing to finish): 
The list has 4 elements and a capacity of 4 elements
Unsorted list-------------------------------------
3,6
7,4
-1,5
-7
Element searched for (nothing to stop): 7,4
Found at position 1
Element searched for (nothing to stop): 0
Not in the list (-1)
Element searched for (nothing to stop): 
Sorted list---------------------------------------
-7
-1,5
3,6
7,4
Element searched for (nothing to stop): 7,4
Found at position 3
Element searched for (nothing to stop): 0
Not in the list (-3)
Element searched for (nothing to stop): 

The results are the same as with the array. The capacity of the list here is 4 (line 7): starting at 0, it increased to 4 on the first addition; it would have increased to 8 on the fifth addition, doubling like that of a [StringBuilder].

5.4.2. List<T> Methods and Collection Expressions

The program [collections-02.cs] uses the other methods of the list and the collection expressions of C# 12, which we already encountered with arrays in Chapter 3:

// the main methods of List<T> and collection expressions

// a list initialized by a collection expression (C# 12)
List<string> cities = ["Paris", "Lyon", "Angers"];
Display("start", cities);

// additions
cities.Add("Nantes");                       // at the end
cities.Insert(1, "Brest");                  // at position 1
cities.AddRange(["Lille", "Tours"]);        // several elements at the end
Display("after Add, Insert, AddRange", cities);

// access by index, read and write
cities[0] = "Marseille";
Console.WriteLine($"cities[0]={cities[0]}, cities[^1]={cities[^1]}, Count={cities.Count}");

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

// removals
bool removed = cities.Remove("Lyon");       // by value
cities.RemoveAt(0);                         // by position
Display($"after Remove ({removed}), RemoveAt(0)", cities);

// sorting, reversing, range of elements
cities.Sort();
Display("after Sort", cities);
cities.Reverse();
Display("after Reverse", cities);
List<string> slice = cities.GetRange(1, 2);
Display("GetRange(1, 2)", slice);

// conversion to an array and back
string[] array = cities.ToArray();
List<string> other = new(array);
Console.WriteLine($"array of {array.Length} elements, new list of {other.Count} elements");

// the .. (spread) operator inserts the elements of a collection into another one
int[] small = [1, 2, 3];
List<int> numbers = [0, .. small, 10, .. small];
Display("numbers", numbers);
// the same syntax creates an array, a list, or an empty collection
int[] empty = [];
Console.WriteLine($"empty.Length={empty.Length}");

// Clear empties the list
cities.Clear();
Console.WriteLine($"after Clear: Count={cities.Count}");

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

Results:

> dotnet run collections-02.cs
start                             : [Paris, Lyon, Angers]
after Add, Insert, AddRange       : [Paris, Brest, Lyon, Angers, Nantes, Lille, Tours]
cities[0]=Marseille, cities[^1]=Tours, Count=7
Contains("Lyon")=True, IndexOf("Angers")=3, IndexOf("Nice")=-1
after Remove (True), RemoveAt(0)  : [Brest, Angers, Nantes, Lille, Tours]
after Sort                        : [Angers, Brest, Lille, Nantes, Tours]
after Reverse                     : [Tours, Nantes, Lille, Brest, Angers]
GetRange(1, 2)                    : [Nantes, Lille]
array of 5 elements, new list of 5 elements
numbers                           : [0, 1, 2, 3, 10, 1, 2, 3]
empty.Length=0
after Clear: Count=0

Comments:

  • line 4: a collection expression [a, b, c] initializes a list like an array;
  • lines 8–10: three ways to add elements;
  • line 14: element #0 is readable and writable; [cities[^1]] is the last element, as with an array;
  • line 21: [Remove] returns [true] if the element was found and removed;
  • line 30: a new list consisting of 2 elements starting at position 1;
  • lines 34–35: conversion from a list to an array, and from an array (or any sequence) to a list;
  • line 40: in a collection expression, the operator [..] (spread) inserts all elements of another collection;
  • Line 43: [] refers to an empty collection of the expected type.

5.4.3. The Dictionary<TKey,TValue> class

The [System.Collections.Generic.Dictionary<TKey,TValue>] class is used to implement a dictionary. A dictionary can be thought of as a two-column array:

key
value
key1
value1
key2
value2
...
...

In the [Dictionary<TKey,TValue>] class, the keys are of type [TKey], and the values are of type [TValue]. Keys are unique: there cannot be two identical keys. Such a dictionary might look like this if the types [TKey] and [TValue] referred to classes:

Image

The value associated with the key [c] in a dictionary [d] is obtained using the notation [d[c]]. This value is both readable and writable. Thus, we can write:

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

When writing, [d[c] = v] adds the pair (c, v) if the key [c] does not exist, and replaces the value associated with [c] otherwise. When reading, if the key [c] does not exist in the dictionary [d], the notation [d[c]] throws a [KeyNotFoundException] exception.

The main methods and properties of the [Dictionary<TKey,TValue>] class are as follows.

Properties:

property
role
[public int Count { get; }]
number of (key, value) entries in the dictionary
[public KeyCollection Keys { get; }]
collection of dictionary keys
[public ValueCollection Values { get; }]
collection of dictionary values

Methods:

Method
Role
[public void Add(TKey key, TValue value)]
Adds the (key, value) pair to the dictionary; throws an exception if the key already exists
[public bool TryAdd(TKey key, TValue value)]
Same as above, but returns [false] instead of throwing an exception
[public void Clear()]
removes all pairs from the dictionary
[public bool ContainsKey(TKey key)]
Returns [true] if [key] is a key in the dictionary; otherwise, returns [false]
[public bool ContainsValue(TValue value)]
returns [true] if [value] is a value in the dictionary, [false] otherwise
[public bool Remove(TKey key)]
removes the key-value pair [key] from the dictionary; returns [true] if the operation succeeds, [false] otherwise
[public bool TryGetValue(TKey key, out TValue value)]
returns [true] and sets [value] to the value associated with the key [key] if the latter exists; returns [false] otherwise, and [value] receives the default value of type [TValue]

Let's consider the sample program [collections-03.cs]:

// the Dictionary<TKey,TValue> class

// creation of a <string,int> dictionary from an array of "name:age" strings
string[] list = ["jean:20", "paul:18", "mélanie:10", "violette:15"];
Dictionary<string, int> dico = new();
foreach (string element in list)
{
    string[] fields = element.Split(':');
    dico[fields[0]] = int.Parse(fields[1]);
}
// number of elements of the dictionary
Console.WriteLine($"The dictionary has {dico.Count} elements");
// list of keys
Display("[List of keys]", dico.Keys);
// list of values
Display("[List of values]", dico.Values);
// list of keys and values
Console.WriteLine("[List of keys & values]");
foreach (string key in dico.Keys)
{
    Console.WriteLine($"key={key} value={dico[key]}");
}
// we remove the key "paul"
Console.WriteLine("[Removal of a key]");
dico.Remove("paul");
// iterating over the (key, value) pairs with deconstruction
Console.WriteLine("[List of keys & values]");
foreach (var (key, value) in dico)
{
    Console.WriteLine($"key={key} value={value}");
}
// Add rejects an existing key, whereas dico[key] = value replaces it
try
{
    dico.Add("jean", 21);
}
catch (ArgumentException e)
{
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}
// TryAdd only adds if the key is absent
Console.WriteLine($"TryAdd(\"jean\", 21)={dico.TryAdd("jean", 21)}, TryAdd(\"zoé\", 12)={dico.TryAdd("zoé", 12)}");
// the dico[key] notation throws an exception if the key is absent
try
{
    Console.WriteLine(dico["x"]);
}
catch (KeyNotFoundException e)
{
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}

// search in the dictionary
while (true)
{
    Console.Write("Name searched for (nothing to stop): ");
    string searchedName = (Console.ReadLine() ?? "").Trim();
    if (searchedName == "")
    {
        break;
    }
    // TryGetValue returns true if the key exists and puts the associated value in age
    if (dico.TryGetValue(searchedName, out int age))
    {
        Console.WriteLine($"{searchedName},{age}");
    }
    else
    {
        Console.WriteLine($"Name {searchedName} unknown");
    }
}

// generic method to display the elements of a sequence
static void Display<T>(string text, IEnumerable<T> elements)
{
    Console.WriteLine(text.PadRight(50, '-'));
    foreach (T element in elements)
    {
        Console.WriteLine(element);
    }
}

Comments:

  • line 4: an array of strings that will be used to initialize the <string,int> dictionary;
  • line 5: the dictionary, which is empty;
  • lines 6–9: its initialization using the array of strings;
  • line 12: the number of entries in the dictionary;
  • lines 14–16: the keys and values of the dictionary. [dico.Keys] and [dico.Values] implement [IEnumerable<T>]: the [Display] method we’ve already encountered is suitable;
  • line 25: deletion of a dictionary entry;
  • line 28: a dictionary is a sequence of pairs [KeyValuePair<TKey,TValue>], each of which has a property [Key] and a property [Value]. Since .NET Core 2.0, such a pair is decomposed into two variables, like a tuple (Chapter 3);
  • lines 35–42: [Add] rejects an existing key by throwing an exception; [TryAdd] returns [false] in this case. The exception messages for .NET are in English: .NET 10 does not provide messages in French;
  • line 46: attempting to read a missing key throws a [KeyNotFoundException] exception;
  • line 63: searching for a key in the dictionary. [TryGetValue] returns [true] if the key exists. The 2008 course ignored this result and checked whether the returned value was null—a technique that worked only because the value 0 was not in the dictionary. Checking the returned Boolean value is the correct approach.

Results:

> dotnet run collections-03.cs
The dictionary has 4 elements
[List of keys]------------------------------------
jean
paul
mélanie
violette
[List of values]----------------------------------
20
18
10
15
[List of keys & values]
key=jean value=20
key=paul value=18
key=mélanie value=10
key=violette value=15
[Removal of a key]
[List of keys & values]
key=jean value=20
key=mélanie value=10
key=violette value=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.
Name searched for (nothing to stop): violette
violette,15
Name searched for (nothing to stop): x
Name x unknown
Name searched for (nothing to stop): jean
jean,20
Name searched for (nothing to stop): 

The keys are listed here in the order they were added, but this is not guaranteed: a *[Dictionary]* does not preserve any order. After deletions, a new element may take the place of a deleted element. If the order of the keys matters, use a *[SortedDictionary]*, shown below.

5.4.4. Other Generic Collections

The [System.Collections.Generic] namespace contains other collections, each suited for a specific purpose:

class
role
[HashSet<T>]
a set: no duplicates, no order, very fast membership test; set operations (union, intersection, difference)
[SortedSet<T>]
a set whose elements are kept sorted
[Queue<T>]
a queue: elements are output in the order in which they were inserted (FIFO, first in, first out)
[Stack<T>]
a stack: the last element to be added is the first to be removed (LIFO, last in first out)
[SortedDictionary<TKey,TValue>]
a dictionary whose keys are kept sorted
[PriorityQueue<TElement,TPriority>]
a priority queue (.NET 6): the element removed is the one with the lowest priority
[LinkedList<T>]
a doubly linked list: fast insertions and deletions in the middle of the list

The program [collections-04.cs] provides a quick overview:

// an overview of other generic collections

// HashSet<T>: a set, without duplicates or order
HashSet<string> languages = ["C#", "Java", "PHP"];
bool added1 = languages.Add("Python");
bool added2 = languages.Add("Java");             // already present: rejected
Console.WriteLine($"HashSet : Add(Python)={added1}, Add(Java)={added2}, Count={languages.Count}, Contains(PHP)={languages.Contains("PHP")}");
HashSet<string> web = ["PHP", "JavaScript", "Python"];
HashSet<string> common = new(languages);
common.IntersectWith(web);                        // intersection
Console.WriteLine($"HashSet : intersection = {{{string.Join(", ", common)}}}");
common.UnionWith(["Go"]);                         // union
Console.WriteLine($"HashSet : after union = {{{string.Join(", ", common)}}}");

// Queue<T>: a queue, first in, first out
Queue<string> queue = new();
queue.Enqueue("client1");
queue.Enqueue("client2");
queue.Enqueue("client3");
Console.WriteLine($"Queue : Peek={queue.Peek()}, Dequeue={queue.Dequeue()}, Dequeue={queue.Dequeue()}, {queue.Count} left");

// Stack<T>: a stack, last in, first out
Stack<int> stack = new();
stack.Push(1);
stack.Push(2);
stack.Push(3);
Console.WriteLine($"Stack : Peek={stack.Peek()}, Pop={stack.Pop()}, Pop={stack.Pop()}, {stack.Count} left");
// TryPop avoids the exception on an empty stack
stack.Pop();
Console.WriteLine($"Stack : TryPop on an empty stack = {stack.TryPop(out int top)}");

// SortedDictionary<TKey,TValue>: a dictionary whose keys are kept sorted
SortedDictionary<string, int> ages = new()
{
    ["violette"] = 15,
    ["jean"] = 20,
    ["mélanie"] = 10,
    ["paul"] = 18
};
Console.WriteLine($"SortedDictionary : {string.Join(", ", ages)}");

// PriorityQueue<TElement,TPriority>: the element with the lowest priority always comes out first
PriorityQueue<string, int> emergencies = new();
emergencies.Enqueue("sprain", 3);
emergencies.Enqueue("cardiac arrest", 1);
emergencies.Enqueue("cold", 5);
emergencies.Enqueue("fracture", 2);
Console.Write("PriorityQueue :");
while (emergencies.TryDequeue(out string? patient, out int priority))
{
    Console.Write($" {patient} ({priority})");
}
Console.WriteLine();

Results:

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 : after union = {Go, PHP, Python}
Queue : Peek=client1, Dequeue=client1, Dequeue=client2, 1 left
Stack : Peek=3, Pop=3, Pop=2, 1 left
Stack : TryPop on an empty stack = False
SortedDictionary : [jean, 20], [mélanie, 10], [paul, 18], [violette, 15]
PriorityQueue : cardiac arrest (1) fracture (2) sprain (3) cold (5)

Comments:

  • line 4: collection expressions also initialize a set;
  • line 6: [Add] returns [false] if the element is already in the set, which remains unchanged;
  • Lines 9–12: [IntersectWith] and [UnionWith] modify the set; therefore, we are working on a copy of [languages]. The order in which the elements of a set are displayed is meaningless (line 4);
  • the double curly braces [{{] and [}}] in an interpolated string display a single curly brace;
  • lines 16–20: [Enqueue] enqueues an element, [Dequeue] dequeues the oldest element, [Peek] retrieves the element without dequeuing it;
  • lines 23–30: [Push] pushes an element onto the stack, [Pop] pops the most recent element. [Pop] on an empty stack throws an exception; [TryPop] returns [false];
  • lines 33–40: a dictionary initialized with the syntax [[key] = value]; its key-value pairs are enumerated in key order;
  • lines 43–49: each element enters the queue with a priority; patients exit in ascending order of priority, regardless of their order of arrival.

5.4.5. Read-Only Collections

A class that manages a collection often needs to expose it externally without allowing it to be modified. .NET provides read-only interfaces for this purpose: [IReadOnlyCollection<T>] (one [Count] and one [foreach]), [IReadOnlyList<T>] (plus index-based access), and [IReadOnlyDictionary<TKey,TValue>]. The classes [List<T>], [T[]], and [Dictionary<TKey,TValue>] implement them. The program [collections-05.cs] uses them:

// read-only collections

var classroom = new Classroom();
classroom.Enroll("dupont");
classroom.Enroll("martin");
// the outside code can read the list of students...
IReadOnlyList<string> students = classroom.Students;
Console.WriteLine($"{students.Count} students: {string.Join(", ", students)}, students[0]={students[0]}");
// ... but the IReadOnlyList<T> interface has neither Add nor Remove: students.Add("x") does not compile

// a cast to List<string> would make it possible to bypass the protection
if (students is List<string> list)
{
    Console.WriteLine("Students is actually a modifiable List<string>");
}
// AsReadOnly returns a read-only wrapper that cannot be bypassed
IReadOnlyList<string> protectedList = classroom.ProtectedStudents;
Console.WriteLine($"ProtectedStudents is of type {protectedList.GetType().Name}");
try
{
    // ReadOnlyCollection<T> implements IList<T>, but its modification methods throw an exception
    ((IList<string>)protectedList).Add("intruder");
}
catch (NotSupportedException e)
{
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}
// the wrapper reflects the modifications made by the owning class
classroom.Enroll("durand");
Console.WriteLine($"{protectedList.Count} students: {string.Join(", ", protectedList)}");

// a read-only dictionary
IReadOnlyDictionary<string, int> grades = new Dictionary<string, int> { ["dupont"] = 12, ["martin"] = 15 };
Console.WriteLine($"grades[martin]={grades["martin"]}, ContainsKey(durand)={grades.ContainsKey("durand")}");

// a class that keeps control of its list
class Classroom
{
    // the list is private: only the class can modify it
    private readonly List<string> _students = [];

    // exposed read-only through an interface
    public IReadOnlyList<string> Students => _students;
    // exposed through a read-only wrapper
    public IReadOnlyList<string> ProtectedStudents => _students.AsReadOnly();

    public void Enroll(string name) => _students.Add(name);
}

Results:

1
2
3
4
5
6
7
> dotnet run collections-05.cs
2 students: dupont, martin, students[0]=dupont
Students is actually a modifiable List<string>
ProtectedStudents is of type ReadOnlyCollection`1
NotSupportedException : Collection is read-only.
3 students: dupont, martin, durand
grades[martin]=15, ContainsKey(durand)=False

Comments:

  • lines 37–47: The class [Classroom] manages a list of students. The list itself is private; only the method [Enroll] allows it to be modified;
  • line 43: the property [Students] casts the list to the type [IReadOnlyList<string>], which supports only read operations: [students.Add("x")] would cause a compilation error;
  • line 12: however, this protection is only superficial: the returned object is the list itself, and a type cast allows you to access its modification methods;
  • line 45: [AsReadOnly] wraps the list in a container of type [ReadOnlyCollection<T>]. This time, no modifications are possible: its modification methods, enforced by the [IList<T>] interface, throw a [NotSupportedException] exception (line 5). The wrapper does not copy the list: it sees the modifications made by the owner class (line 6);
  • line 33: a dictionary viewed through the [IReadOnlyDictionary<TKey,TValue>] interface.

For data that must never change after creation, the [System.Collections.Immutable] namespace provides immutable collections ([ImmutableList<T>], [ImmutableDictionary<TKey,TValue>]…) and [System.Collections.Frozen] offers read-optimized frozen collections ([FrozenDictionary<TKey,TValue>], .NET 8). We will not go into detail about them.

5.5. LINQ

The 2008 course ended just before LINQ, introducing its anonymous types without actually using them. LINQ (Language Integrated Query, C# 3, 2007) is a set of methods that allow you to query a collection just as you would query a database: filter, sort, group, calculate totals, and so on. It applies to any collection that implements the [IEnumerable<T>] interface: arrays, lists, dictionaries, sets, lines in a file, results of a regular expression, and, with Entity Framework Core (Chapter 11), database tables. The [System.Linq] namespace is part of the implicit [using] namespace.

5.5.1. The IEnumerable<T> sequences and iterators

A collection that implements [IEnumerable<T>] is called a sequence: it can be iterated over using [foreach]. The interface contains only one method, [GetEnumerator], which returns an enumerator [IEnumerator<T>]: an object that moves through the sequence ([MoveNext]) and returns the current element ([Current]).

A method can produce a sequence without constructing a collection: this is an iterator. It returns its elements one by one using the [yield return] statement. The program [linq-01.cs] provides two examples:

// IEnumerable<T> sequences and iterators (yield return)

// 1 - foreach on a sequence produced by an iterator
Console.WriteLine("[foreach on Evens(6)]");
foreach (int n in Evens(6))
{
    Console.WriteLine($"  received {n}");
}

// 2 - what foreach does: it asks the sequence for an enumerator
Console.WriteLine("[enumeration by hand]");
IEnumerable<int> sequence = Evens(4);
using (IEnumerator<int> enumerator = sequence.GetEnumerator())
{
    while (enumerator.MoveNext())
    {
        Console.WriteLine($"  Current={enumerator.Current}");
    }
}

// 3 - an infinite sequence: it is only produced as and when needed
Console.WriteLine("[infinite sequence]");
foreach (long f in Fibonacci())
{
    if (f > 100)
    {
        break;
    }
    Console.Write($"{f} ");
}
Console.WriteLine();

// 4 - an array, a list, a string are also IEnumerable<T>
IEnumerable<char> letters = "abc";
IEnumerable<string> words = new List<string> { "one", "two" };
Console.WriteLine($"[other sequences] {string.Join("-", letters)} {string.Join("-", words)}");

// the even numbers from 0 to max
static IEnumerable<int> Evens(int max)
{
    Console.WriteLine("  (start of the iterator)");
    for (int i = 0; i <= max; i += 2)
    {
        Console.WriteLine($"  (production of {i})");
        // we return i to the calling code; we will resume here on the next request
        yield return i;
    }
    Console.WriteLine("  (end of the iterator)");
}

// the Fibonacci sequence, endless
static IEnumerable<long> Fibonacci()
{
    long a = 0, b = 1;
    while (true)
    {
        yield return a;
        (a, b) = (b, a + b);
    }
}

Results:

> dotnet run linq-01.cs
[foreach on Evens(6)]
  (start of the iterator)
  (production of 0)
  received 0
  (production of 2)
  received 2
  (production of 4)
  received 4
  (production of 6)
  received 6
  (end of the iterator)
[enumeration by hand]
  (start of the iterator)
  (production of 0)
  Current=0
  (production of 2)
  Current=2
  (production of 4)
  Current=4
  (end of the iterator)
[infinite sequence]
0 1 1 2 3 5 8 13 21 34 55 89 
[other sequences] a-b-c one-two

Comments:

  • Lines 39–48: The method [Evens] is an iterator: its return type is *[IEnumerable<int>], and it contains the statement [yield return] (line 46). It does not construct any list: for each [yield return], it returns an element to the code that iterates through it and pauses*; it will resume at the next statement when asked for the next element;
  • lines 5–7: the results (lines 3–12) show that the production of an element by the iterator and its consumption by the [foreach] loop alternate;
  • lines 12–17: What [foreach] actually does: it requests an enumerator from the sequence, calls [MoveNext] as long as that method returns [true], and reads the current element from [Current]. The iterator is released at the end by the instruction [using] (we’ll come back to this when discussing files). Note that the call to [Evens(4)] on line 12 does nothing: the message “start of the iterator” only appears on the first call to [MoveNext];
  • lines 52–58: an iterator can produce an infinite sequence; it calculates only the requested elements. It is the [foreach] loop that decides when to stop (line 25). Line 58 uses the tuples from Chapter 3 to exchange values;
  • lines 34–35: A string is a sequence of characters; a list is a sequence of its elements. The list is created here using the [new List<string> { ... }] collection initializer syntax (C# 3), which is commonly found in existing code.

This on-demand generation mechanism is used by all methods in LINQ.

5.5.2. Filtering, Projecting, Sorting

The following examples work with a small list of people. A person is represented by a [record] (C# 9) record, introduced in Chapter 4: in a single line, we get a class with a constructor, read-only properties, a [ToString] method that displays the properties, and an equality operator that compares the property values.

The program [linq-02.cs] demonstrates the basic methods of LINQ:

// LINQ: filtering (Where), projecting (Select), sorting (OrderBy, ThenBy)

// the data: a list of persons
List<Person> persons =
[
    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: the adults; p => p.Age >= 18 is an anonymous function
IEnumerable<Person> adults = persons.Where(p => p.Age >= 18);
Display("[Where: the adults]", adults);

// Select: a projection, here each person becomes a string
IEnumerable<string> names = persons.Select(p => $"{p.FirstName} {p.LastName.ToUpper()}");
Display("[Select: first name LAST NAME]", names);

// operators can be chained: the names of the people of Angers, in alphabetical order
var angersPeople = persons
    .Where(p => p.City == "Angers")
    .OrderBy(p => p.LastName)
    .Select(p => p.LastName);
Display("[Where + OrderBy + Select: the people of Angers]", angersPeople);

// sorting on several criteria: descending age, then ascending last name
var sorted = persons.OrderByDescending(p => p.Age).ThenBy(p => p.LastName);
Display("[OrderByDescending + ThenBy]", sorted);

// the same query as 'angersPeople' written with the query syntax
var angersPeople2 = from p in persons
                    where p.City == "Angers"
                    orderby p.LastName
                    select p.LastName;
Display("[query syntax: the people of Angers]", angersPeople2);

// Select can also provide the position of the element
var numbered = persons.Take(3).Select((p, i) => $"{i + 1}. {p.LastName}");
Display("[Select with index]", numbered);

// display of a sequence
static void Display<T>(string title, IEnumerable<T> sequence)
{
    Console.WriteLine(title);
    foreach (T element in sequence)
    {
        Console.WriteLine($"  {element}");
    }
}

// a person
record Person(string LastName, string FirstName, int Age, string City);

Results:

> dotnet run linq-02.cs
[Where: the adults]
  Person { LastName = dupont, FirstName = jean, Age = 45, City = Angers }
  Person { LastName = durand, FirstName = paul, Age = 32, City = Angers }
  Person { LastName = leroy, FirstName = sophie, Age = 28, City = Nantes }
  Person { LastName = simon, FirstName = julie, Age = 52, City = Angers }
  Person { LastName = laurent, FirstName = marc, Age = 32, City = Lyon }
[Select: first name LAST NAME]
  jean DUPONT
  marie MARTIN
  paul DURAND
  sophie LEROY
  luc MOREAU
  julie SIMON
  marc LAURENT
[Where + OrderBy + Select: the people of Angers]
  dupont
  durand
  simon
[OrderByDescending + ThenBy]
  Person { LastName = simon, FirstName = julie, Age = 52, City = Angers }
  Person { LastName = dupont, FirstName = jean, Age = 45, City = Angers }
  Person { LastName = durand, FirstName = paul, Age = 32, City = Angers }
  Person { LastName = laurent, FirstName = marc, Age = 32, City = Lyon }
  Person { LastName = leroy, FirstName = sophie, Age = 28, City = Nantes }
  Person { LastName = martin, FirstName = marie, Age = 17, City = Paris }
  Person { LastName = moreau, FirstName = luc, Age = 15, City = Paris }
[query syntax: the people of Angers]
  dupont
  durand
  simon
[Select with index]
  1. dupont
  2. martin
  3. durand

Comments:

  • lines 4–13: the list of people, initialized by a collection expression; [new(...)] creates an object of the expected type, in this case [Person];
  • line 56: the record [Person]. A single-file application places its type declarations after the high-level statements;
  • line 16: the method [Where] filters the sequence: it retains only the elements that satisfy a condition. This condition is passed to it in the form of an anonymous function [p => p.Age >= 18], which reads “the function that, for a person p, associates the Boolean p.Age >= 18.” This notation is called a lambda expression. [Where] calls this function for each person. Lambdas will be studied in detail in Chapter 7 (“Delegates, Lambdas, and Events”; for now, it is sufficient to know how to read them);
  • The result of [Where] is a new sequence, [IEnumerable<Person>]: the list [persons] remains unchanged. This is true for all methods of LINQ;
  • the [Display] method displays each person using the [ToString] method generated for the record: [Person { LastName = dupont, ... }];
  • Line 20: The method [Select] maps each element of the sequence to a new value, in this case a string. The result is a sequence [IEnumerable<string>];
  • lines 24–27: the methods of LINQ return sequences, to which other methods of LINQ can be applied. Methods are often written one per line: this chain of calls reads from top to bottom like a recipe;
  • line 26: [OrderBy] sorts the sequence according to the key returned by the lambda, in this case the name;
  • line 31: [OrderByDescending] sorts in descending order; [ThenBy] (or [ThenByDescending]) adds a secondary criterion, used to distinguish between elements that are equal according to the first criterion: Durand and Laurent, who are both 32 years old, are sorted by name (lines 23–24);
  • lines 35–38: the same query as [angersPeople] written using the query syntax, similar to SQL. The compiler translates it exactly into the call chain [Where(...).OrderBy(...).Select(...)]: the two notations are equivalent. The query syntax is more readable for joins and groupings; the method syntax is more common, and it alone provides access to all the methods of LINQ ([Take], [Count], [First]…);
  • Line 42: A variant of [Select] passes the element and its position in the sequence to the lambda.

5.5.3. Quantifiers, aggregates, element selection

The program [linq-03.cs] includes methods that return a single value and methods that select a portion of the sequence:

// LINQ: quantifiers, aggregates, choice of elements, partitions

List<Person> persons =
[
    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"),
];

// quantifiers: Any (at least one), All (all)
Console.WriteLine($"Any(minor)={persons.Any(p => p.Age < 18)}, All(minor)={persons.All(p => p.Age < 18)}, Any()={persons.Any()}");

// aggregates
Console.WriteLine($"Count()={persons.Count()}, Count(Angers)={persons.Count(p => p.City == "Angers")}");
Console.WriteLine($"Sum(age)={persons.Sum(p => p.Age)}, Average(age)={persons.Average(p => p.Age):F2}");
Console.WriteLine($"Min(age)={persons.Min(p => p.Age)}, Max(age)={persons.Max(p => p.Age)}");
// MinBy, MaxBy return the element itself and not the minimum or maximum value
Console.WriteLine($"MinBy(age)={persons.MinBy(p => p.Age)?.LastName}, MaxBy(age)={persons.MaxBy(p => p.Age)?.LastName}");

// choice of an element
Console.WriteLine($"First()={persons.First().LastName}, First(Paris)={persons.First(p => p.City == "Paris").LastName}, Last()={persons.Last().LastName}");
Person? unknown = persons.FirstOrDefault(p => p.City == "Brest");
Console.WriteLine($"FirstOrDefault(Brest)={unknown?.LastName ?? "null"}");
Console.WriteLine($"Single(Lyon)={persons.Single(p => p.City == "Lyon").LastName}");
try
{
    // Single requires exactly one element
    _ = persons.Single(p => p.City == "Angers");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"Single(Angers) : {e.GetType().Name} : {e.Message}");
}
try
{
    // First requires at least one element
    _ = persons.First(p => p.City == "Brest");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"First(Brest) : {e.GetType().Name} : {e.Message}");
}

// Distinct: removes duplicates
var cities = persons.Select(p => p.City).Distinct();
Console.WriteLine($"Distinct(cities)=[{string.Join(", ", cities)}]");
// DistinctBy: one element per key value
var onePerCity = persons.DistinctBy(p => p.City).Select(p => p.LastName);
Console.WriteLine($"DistinctBy(city)=[{string.Join(", ", onePerCity)}]");

// partitions: Take, Skip, TakeWhile
var names = persons.Select(p => p.LastName);
Console.WriteLine($"Take(2)=[{string.Join(", ", names.Take(2))}], Skip(5)=[{string.Join(", ", names.Skip(5))}]");
Console.WriteLine($"Skip(2).Take(2)=[{string.Join(", ", names.Skip(2).Take(2))}], TakeWhile(length 6)=[{string.Join(", ", names.TakeWhile(n => n.Length == 6))}]");

// Chunk: splitting into packets of fixed size (pagination)
int page = 1;
foreach (Person[] packet in persons.Chunk(3))
{
    Console.WriteLine($"page {page++} : {string.Join(", ", packet.Select(p => p.LastName))}");
}

record Person(string LastName, string FirstName, int Age, string City);

Results:

> dotnet run linq-03.cs
Any(minor)=True, All(minor)=False, Any()=True
Count()=7, Count(Angers)=3
Sum(age)=221, Average(age)=31.57
Min(age)=15, Max(age)=52
MinBy(age)=moreau, MaxBy(age)=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(cities)=[Angers, Paris, Nantes, Lyon]
DistinctBy(city)=[dupont, martin, leroy, laurent]
Take(2)=[dupont, martin], Skip(5)=[simon, laurent]
Skip(2).Take(2)=[durand, leroy], TakeWhile(length 6)=[dupont, martin, durand]
page 1 : dupont, martin, durand
page 2 : leroy, moreau, simon
page 3 : laurent

Comments:

  • Line 15: [Any] returns [true] if at least one element satisfies the condition, and [All] if all of them satisfy it. Without parameters, [Any] indicates whether the sequence is non-empty;
  • Lines 18–20: The aggregates [Count], [Sum], [Average], [Min], [Max] calculate a value based on the entire sequence. [Count] accepts a condition (a list also has a property [Count], which is preferred over the method when there is no condition); [Sum], [Average], [Min], and [Max] accept a lambda that specifies the value to be considered for each element;
  • Line 22: [MinBy] and [MaxBy] (.NET 6) return the element (in this case, the person) for which the key is the minimum or maximum, not the key value itself. For an empty sequence of objects (reference type), they return [null], hence the operator [?.];
  • line 25: [First] returns the first element of the sequence, or the first one that satisfies a condition; [Last] returns the last one;
  • Line 26: If no element matches, [First] throws an exception (line 11 of the results), whereas [FirstOrDefault] returns the default value for the type, in this case [null];
  • Line 28: [Single] returns the single element that satisfies the condition and throws an exception if there are zero or more such elements (line 10). This is used when uniqueness is a rule of the data, such as searching for an element by its key. [SingleOrDefault] returns the default value if there are no elements;
  • line 32: the result of the call is not used; it is assigned to the ignored element [_] (C# 7) to flag it;
  • line 49: [Distinct] removes duplicates; [DistinctBy] (.NET 6) keeps the first element of each key value;
  • lines 57–58: [Take(n)] keeps the first n elements, [Skip(n)] skips them. Combined, they extract a “page” of results. [TakeWhile] keeps the elements as long as a condition is met and stops at the first element that does not meet it;
  • line 62: [Chunk] (.NET 6) splits the sequence into arrays of 3 elements, the last of which may be shorter;
  • line 64: the variable [page] is incremented after being used in the interpolation.

5.5.4. Anonymous Types, Groupings, Joins

The program [linq-04.cs] introduces anonymous types and more complex operations:

// LINQ: anonymous types, groupings, joins, conversions

List<Person> persons =
[
    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<City> cities =
[
    new("Angers", "Maine-et-Loire"),
    new("Nantes", "Loire-Atlantique"),
    new("Paris", "Paris"),
    new("Brest", "Finistère"),
];

// 1 - an anonymous type: an object whose type, without a name, is created by the compiler
var p1 = new { LastName = "dupont", Age = 45 };
Console.WriteLine($"[anonymous type] {p1}, p1.LastName={p1.LastName}");
// Select to an anonymous type: we only keep the useful properties
var summaries = persons.Where(p => p.Age > 40).Select(p => new { p.LastName, Decade = p.Age / 10 * 10 });
foreach (var r in summaries)
{
    Console.WriteLine($"  {r.LastName} is over {r.Decade} years old");
}

// 2 - GroupBy: grouping the persons by city
Console.WriteLine("[GroupBy]");
foreach (IGrouping<string, Person> group in persons.GroupBy(p => p.City))
{
    // a group has a key (Key) and is itself a sequence of persons
    Console.WriteLine($"  {group.Key} : {group.Count()} person(s), average age {group.Average(p => p.Age):F1} ({string.Join(", ", group.Select(p => p.LastName))})");
}

// 3 - Join: association of persons and cities on the name of the city
Console.WriteLine("[Join]");
var join = persons.Join(cities,
    p => p.City,                                     // key on the persons side
    c => c.Name,                                     // key on the cities side
    (p, c) => new { p.LastName, c.Department });     // what we keep from a pair
foreach (var j in join)
{
    Console.WriteLine($"  {j.LastName} lives in the department {j.Department}");
}
// the same join in query syntax: Lyon, absent from the list of cities, also disappears
var join2 = from p in persons
            join c in cities on p.City equals c.Name
            where p.Age >= 18
            select $"{p.LastName}/{c.Department}";
Console.WriteLine($"[join in query syntax] {string.Join(", ", join2)}");

// 4 - conversions: ToList, ToArray, ToDictionary
List<string> adultNames = persons.Where(p => p.Age >= 18).Select(p => p.LastName).ToList();
Dictionary<string, int> agesByName = persons.ToDictionary(p => p.LastName, p => p.Age);
Console.WriteLine($"[ToList] {adultNames.Count} adults; [ToDictionary] agesByName[\"leroy\"]={agesByName["leroy"]}");

// 5 - .NET 9: CountBy, AggregateBy, Index
Console.WriteLine("[CountBy]");
foreach (KeyValuePair<string, int> pair in persons.CountBy(p => p.City))
{
    Console.WriteLine($"  {pair.Key}={pair.Value}");
}
Console.WriteLine("[AggregateBy: sum of the ages by city]");
foreach (var (city, total) in persons.AggregateBy(p => p.City, 0, (sum, p) => sum + p.Age))
{
    Console.WriteLine($"  {city}={total}");
}
Console.WriteLine("[Index]");
foreach (var (index, person) in persons.Where(p => p.Age < 30).Index())
{
    Console.WriteLine($"  {index} : {person.LastName}");
}

record Person(string LastName, string FirstName, int Age, string City);
record City(string Name, string Department);

Results:

> dotnet run linq-04.cs
[anonymous type] { LastName = dupont, Age = 45 }, p1.LastName=dupont
  dupont is over 40 years old
  simon is over 50 years old
[GroupBy]
  Angers : 3 person(s), average age 43.0 (dupont, durand, simon)
  Paris : 2 person(s), average age 16.0 (martin, moreau)
  Nantes : 1 person(s), average age 28.0 (leroy)
  Lyon : 1 person(s), average age 32.0 (laurent)
[Join]
  dupont lives in the department Maine-et-Loire
  martin lives in the department Paris
  durand lives in the department Maine-et-Loire
  leroy lives in the department Loire-Atlantique
  moreau lives in the department Paris
  simon lives in the department Maine-et-Loire
[join in query syntax] dupont/Maine-et-Loire, durand/Maine-et-Loire, leroy/Loire-Atlantique, simon/Maine-et-Loire
[ToList] 5 adults; [ToDictionary] agesByName["leroy"]=28
[CountBy]
  Angers=3
  Paris=2
  Nantes=1
  Lyon=1
[AggregateBy: sum of the ages by city]
  Angers=129
  Paris=32
  Nantes=28
  Lyon=32
[Index]
  0 : martin
  1 : leroy
  2 : moreau

Comments:

  • Line 22: an anonymous type (C# 3): the expression [new { LastName = ..., Age = ... }] creates an object whose nameless class is generated by the compiler with two read-only properties, [LastName] and [Age]. Since this type has no name, the variable must be declared as [var]. The compiler, however, is fully aware of this type: [p1.LastName] is validated at compile time, and the editor suggests the properties of [p1] in autocomplete;
  • line 25: the main use of anonymous types: in a [Select], keep only the useful data without having to declare a class for it. [p.LastName] is an abbreviation for [LastName = p.LastName];
  • Line 33: [GroupBy] groups elements with the same key. The result is a sequence of groups [IGrouping<TKey,TElement>]: each group has a property [Key], the common value of the key, and is itself the sequence of the group’s elements, to which we can apply [Count], [Average], [Select]… (line 36);
  • lines 41–44: [Join] associates each person with the city of the same name. Its parameters are the second sequence, the join key on each side, and a lambda that constructs the result from a pair (person, city). Like a join SQL, [Join] retains only the pairs that match: Laurent, who lives in Lyon—which is absent from the list of cities—does not appear, nor does Brest, which has no residents;
  • lines 50–53: the join in query syntax, which is more readable: [join ... in ... on ... equals ...]. A filter, [where], is added to it. The query syntax also includes a clause, [group ... by ...], for groupings;
  • line 57: [ToList] (and [ToArray]) executes the query and stores its results in a list (an array);
  • line 58: [ToDictionary] constructs a dictionary: the first lambda provides the key for each element, the second its value. A duplicate key causes an exception;
  • Line 63: [CountBy] (.NET 9) counts the elements of each key value. It returns a sequence of [KeyValuePair<string,int>] pairs. This is a shortcut for [GroupBy(p => p.City).Select(g => (g.Key, g.Count()))];
  • Line 68: [AggregateBy] (.NET 9) accumulates a value for each key value: starting from 0, for each person, their age is added to the sum of their city. The returned tuples are decomposed in the loop [foreach];
  • line 73: [Index] (.NET 9) associates each element with its position in the sequence, in the form of a tuple (position, element).

5.5.5. Deferred Execution

A LINQ query is not executed when it is written, but when its result is iterated over: this is deferred execution. The program [linq-05.cs] demonstrates this using lambdas that display what they are doing:

// LINQ: deferred execution

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

// 1 - defining a query does not execute it
Console.WriteLine("[definition of the query]");
IEnumerable<int> evenSquares = numbers
    .Where(n =>
    {
        Console.WriteLine($"  Where examines {n}");
        return n % 2 == 0;
    })
    .Select(n =>
    {
        Console.WriteLine($"  Select computes {n}*{n}");
        return n * n;
    });
Console.WriteLine("[the query is defined, nothing has been executed yet]");

// 2 - it is executed when it is iterated over, element by element
Console.WriteLine("[foreach]");
foreach (int c in evenSquares)
{
    Console.WriteLine($"  foreach receives {c}");
}

// 3 - the query is re-evaluated on each iteration: it sees the modifications of the source
IEnumerable<int> large = numbers.Where(n => n > 4);
Console.WriteLine($"[before adding] large=[{string.Join(", ", large)}]");
numbers.Add(10);
Console.WriteLine($"[after adding 10] large=[{string.Join(", ", large)}]");

// 4 - ToList executes the query immediately and stores the result
List<int> snapshot = numbers.Where(n => n > 4).ToList();
numbers.Add(20);
Console.WriteLine($"[after adding 20] large=[{string.Join(", ", large)}], snapshot=[{string.Join(", ", snapshot)}]");

// 5 - aggregates (Count, Sum, First...) are also executed immediately
int howMany = numbers.Count(n => n > 4);
numbers.Add(30);
Console.WriteLine($"[after adding 30] howMany={howMany}, new Count={numbers.Count(n => n > 4)}");

// 6 - First stops the iteration as soon as it has found an element
Console.WriteLine("[First on a query]");
int first = numbers.Where(n =>
{
    Console.WriteLine($"  Where examines {n}");
    return n > 2;
}).First();
Console.WriteLine($"  first={first}");

Results:

> dotnet run linq-05.cs
[definition of the query]
[the query is defined, nothing has been executed yet]
[foreach]
  Where examines 1
  Where examines 2
  Select computes 2*2
  foreach receives 4
  Where examines 3
  Where examines 4
  Select computes 4*4
  foreach receives 16
  Where examines 5
  Where examines 6
  Select computes 6*6
  foreach receives 36
[before adding] large=[5, 6]
[after adding 10] large=[5, 6, 10]
[after adding 20] large=[5, 6, 10, 20], snapshot=[5, 6, 10]
[after adding 30] howMany=4, new Count=5
[First on a query]
  Where examines 1
  Where examines 2
  Where examines 3
  first=3

Comments:

  • Lines 7–16: A query that selects even numbers and then calculates their squares. The lambdas here are written with a body enclosed in curly braces, which displays a message before returning its value using [return];
  • Line 3 of the results: After the query was defined, nothing was displayed: neither [Where] nor [Select] has examined a single element yet. [evenSquares] does not contain results, but rather a description of a calculation;
  • Lines 4–16: The query runs during the [foreach] loop, element by element: each number passes through [Where], then [Select] if it is even, and then enters the loop before the next number is examined. These are iterators, like [Evens] within [linq-01.cs];
  • lines 28–31: the query [large] is re-evaluated on each pass: it takes into account the number 10 added to the list after its definition;
  • line 34: [ToList] executes the query immediately and stores its results: [snapshot] is a list that does not see the addition of 20 (line 19);
  • line 39: Methods that return a single value ([Count], [Sum], [First], [Any]…) also iterate through the sequence immediately;
  • lines 45–49: [First] stops iterating as soon as it finds an element: the numbers following 3 are never examined.

Deferred execution has two practical consequences:

  • a query that is traversed multiple times is executed multiple times. If its computation is costly, or if a fixed result is desired, it is converted once and for all using [ToList] or [ToArray];
  • queries can be composed at no cost: chaining [Where], [Select], and [Take] creates no intermediate collection, and only the necessary elements are computed.

We will encounter LINQ throughout the course, and we will see in Chapter 7 how its methods are constructed.

5.6. Text Files

The file-reading and file-writing classes are located in the [System.IO] namespace, which is part of the implicit [using] namespace.

5.6.1. The StreamReader class

The [System.IO.StreamReader] class allows you to read the contents of a text file. It is actually capable of handling streams that are not files (a network connection, for example). Here are some of its properties and methods.

Constructors:

constructor
role
[public StreamReader(string path)]
Creates a read stream from the file at path [path]. The file's content can be encoded in various ways; by default, the UTF-8 encoding is used. Another overload allows you to specify the encoding: [new StreamReader(path, Encoding.Latin1)]

Properties:

property
role
[public bool EndOfStream { get; }]
[true] if the stream has been read in its entirety

Methods:

Method
Role
[public void Dispose()]
Closes the stream and releases the resources allocated for its management. This must be done after the stream has been processed, which is handled by the [using] statement. [Close] does the same thing
[public int Peek()]
returns the next character from the stream without consuming it; -1 at the end of the stream
[public int Read()]
returns the next character from the stream and advances by one character; -1 at the end of the stream
[public string? ReadLine()]
returns the next line from the stream, without the end-of-line character, or [null] if at the end of the stream
[public string ReadToEnd()]
returns the end of the stream, or "" if at the end of the stream

The program [files-01.cs] is as follows:

// reading a text file with StreamReader

// the current folder: this is where the files designated by a relative name are searched for
Console.WriteLine($"Current folder: {Path.GetFileName(Environment.CurrentDirectory)}");
// the file name can be passed as an argument; otherwise infos.txt
string fileName = args.Length > 0 ? args[0] : "infos.txt";
try
{
    // reading 1: line by line
    Console.WriteLine("Reading 1----------------");
    using (var stream = new StreamReader(fileName))
    {
        string? line;
        // ReadLine returns null at the end of the file
        while ((line = stream.ReadLine()) != null)
        {
            Console.WriteLine(line);
        }
    } // here the stream is closed

    // reading 2: the whole file at once, with a using declaration
    Console.WriteLine("Reading 2----------------");
    using var stream2 = new StreamReader(fileName);
    Console.Write(stream2.ReadToEnd());
    // stream2 will be closed at the end of the block containing its declaration, here the try block
}
catch (FileNotFoundException e)
{
    // e.FileName is the full path of the file; we only display its name
    Console.WriteLine($"{e.GetType().Name} : the file [{Path.GetFileName(e.FileName)}] does not exist");
}
catch (IOException e)
{
    // the other input-output errors
    Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}

Comments:

  • line 4: displays the name of the current directory, the one from which the [dotnet run] command was launched. Only the last element of the path is displayed, along with [Path.GetFileName], to avoid cluttering the results with a full path;
  • line 6: The file read is [infos.txt] or the one passed as an argument. Since this is a relative path, it is searched for in the current folder;
  • line 11: The [using (stream = ...) { ... }] structure ensures that the stream is closed when the block ends, even if an exception occurs within the block. This eliminates the need to explicitly call the close method. It applies to any object that implements the [IDisposable] interface—that is, any object with a [Dispose] method that releases resources (file, network connection, database connection, etc.);
  • lines 15–17: The file is read line by line. The assignment [line = stream.ReadLine()] is an expression whose value is that of [line]: it is compared to [null], which indicates the end of the file. The variable [line] is of type [string?]: [ReadLine] can be assigned the value [null];
  • line 23: a using declaration (C# 8). There is no longer a block: the variable [stream2] will be released at the end of the block containing its declaration, in this case the block *[try]*. This more concise syntax is the most common today;
  • line 24: the file is read in one go;
  • lines 27–32: I/O errors are signaled by exceptions derived from [IOException]*. *[FileNotFoundException]* is one such exception; it is therefore caught first. Its property *[FileName] contains the full path of the file being searched for.

The file [infos.txt] is as follows:

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

It is located in the [05-classes-dotnet] folder, from which the program is executed:

> dotnet run files-01.cs
Current folder: 05-classes-dotnet
Reading 1----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5
Reading 2----------------
12620:0:0
13190:0,05:631
15640:0,1:1290,5

With a nonexistent file:

1
2
3
4
> dotnet run files-01.cs -- xx.txt
Current folder: 05-classes-dotnet
Reading 1----------------
FileNotFoundException : the file [xx.txt] does not exist

Selecting the current folder to locate data files is the natural approach for a single-file application that is run from its own folder. For a project, the executable is located in a subfolder named [bin/Debug/net10.0] within the project: we’ll see in the sample application how to copy the data files there and how to locate them.

5.6.2. The StreamWriter class

The [System.IO.StreamWriter] class allows you to write to a text file. Like the [StreamReader] class, it is actually capable of handling streams that are not files. Here are some of its properties and methods.

Constructors:

constructor
role
[public StreamWriter(string path)]
creates a write stream to the file at path [path]. The file is created, or overwritten if it exists. By default, the text is encoded in UTF-8, without BOM (see below)
[public StreamWriter(string path, bool append)]
If [append] equals [true], the entries are appended to the end of the existing file

Properties:

property
role
[public bool AutoFlush { get; set; }]
sets the write mode for the file. If [AutoFlush] is equal to [false] (default value), writing to the stream is not immediate: the text is first written to a buffer, then to the file when the buffer is full, when it is explicitly flushed using the [Flush] method, or when the stream is closed. This is the most efficient mode for files because it limits disk access. It is not suitable for all streams, particularly network streams: in a dialogue between two partners, what one writes must be read immediately by the other, and the write stream must be in [AutoFlush=true] mode
[public string NewLine { get; set; }]
the end-of-line character written by [WriteLine]. By default, this is the system’s end-of-line character: "\r\n" on Windows, "\n" on Linux, and macOS
[public Encoding Encoding { get; }]
the encoding used

Methods:

Method
role
[public void Dispose()]
Closes the stream after writing the buffer's contents to the file. Must be performed after processing the stream; [Close] performs the same action
[public void Flush()]
writes the contents of the buffer to the file without waiting for it to be full
[public void Write(T value)]
writes [value] to the file associated with the stream. Here, [T] is not a generic type but symbolizes the fact that the method [Write] accepts different types of parameters ([string], [int], [object]…)
[public void WriteLine(T value)]
Same as [Write] but with the line-end marker ([NewLine]) added

The program [files-02.cs] is as follows:

// writing a text file with StreamWriter
const string fileName = "infos2.txt";
try
{
    // creation of the text file (if it already exists, it is overwritten)
    using (var stream = new StreamWriter(fileName))
    {
        Console.WriteLine($"AutoFlush mode: {stream.AutoFlush}, encoding: {stream.Encoding.WebName}");
        // loop as long as the typed line is not empty
        while (true)
        {
            Console.Write("line (nothing to stop): ");
            string line = (Console.ReadLine() ?? "").Trim();
            if (line == "")
            {
                break;
            }
            // writing the line into the text file
            stream.WriteLine(line);
        }
    } // closing the stream: the buffer is written into the file

    // check: we read the created file again
    Console.WriteLine($"Content of {fileName} ({new FileInfo(fileName).Length} bytes):");
    using var reader = new StreamReader(fileName);
    Console.Write(reader.ReadToEnd());
}
catch (IOException e)
{
    Console.WriteLine($"The following error occurred: {e.Message}");
}

Results:

1
2
3
4
5
6
7
8
> dotnet run files-02.cs
AutoFlush mode: False, encoding: utf-8
line (nothing to stop): 1st line
line (nothing to stop): 2nd line
line (nothing to stop): 
Content of infos2.txt (18 bytes):
1st line
2nd line

Comments:

  • line 6: once again, we use the [using] statement so that we don’t have to explicitly close the stream. This closure occurs automatically at the end of the block (closing curly brace on line 21). It is this closure that writes the text remaining in the buffer to the file; without it, the file might remain incomplete;
  • line 19: writes a line to the text file;
  • line 24: the [FileInfo] class provides information about a file, in this case its size in bytes. The 18 bytes break down as follows: 16 characters, plus two one-byte line breaks ("\n" on Linux, where the code was run; on Windows, each line break "\r\n" would take up 2 bytes);
  • line 28: why a [try / catch]? We could specify a filename in the form [dir1/dir2/file] with a directory [dir1/dir2] that does not exist, making it impossible to create the file. A [DirectoryNotFoundException] exception, derived from [IOException], would then be thrown. There are other possible exception cases (disk full, insufficient permissions, etc.).

5.6.3. The File, Path, and Directory Classes

For common operations, the static class [File] eliminates the need to handle streams: a single method opens the file, reads or writes it in its entirety, and then closes it. The static classes [Path] and [Directory] handle paths and directories, respectively.

method
role
[File.ReadAllText(path)]
returns the file’s contents as a string
[File.ReadAllLines(path)]
Returns the lines of the file in an array [string[]]
[File.ReadLines(path)]
returns the lines of the file as a [IEnumerable<string>] sequence read on demand
[File.WriteAllText(path, contents)]
creates (or overwrites) the file and writes [contents] to it; [WriteAllLines] writes a collection of lines
[File.AppendAllText(path, contents)], [File.AppendAllLines(path, lines)]
adds text—lines—to the end of the file (which is created if it does not exist)
[File.ReadAllBytes(path)], [File.WriteAllBytes(path, bytes)]
reads and writes the file as a byte array
[File.Exists(path)], [File.Delete(path)], [File.Copy(source, dest)], [File.Move(source, dest)]
checks if a file exists, deletes it, copies it, or moves it
[Path.Combine(p1, p2, ...)]
constructs a path from its elements, using the system separator
[Path.GetFileName(path)], [Path.GetExtension(path)], [Path.GetDirectoryName(path)]
extracts the filename, file extension, and folder from a path
[Directory.CreateDirectory(path)]
creates the folder and its parent folders if they do not exist
[Directory.GetFiles(path, pattern)], [Directory.EnumerateFiles(path, pattern)]
files in the folder whose names match the pattern ([*.txt]…), in an array or sequence
[Directory.Exists(path)], [Directory.Delete(path, recursive)]
checks for the existence of a folder and deletes it (along with its contents if [recursive] equals [true])

The program [files-03.cs] uses them:

using System.Text;

// the static classes File, Path and Directory

// Path.Combine builds a path with the system separator (\ on Windows, / on Linux)
string folder = "data";
string path = Path.Combine(folder, "cities.txt");
Console.WriteLine($"path={path}, name={Path.GetFileName(path)}, without extension={Path.GetFileNameWithoutExtension(path)}, extension={Path.GetExtension(path)}");

// Directory.CreateDirectory creates the folder (and its parents) if it does not exist
Directory.CreateDirectory(folder);

// File.WriteAllText: creates (or overwrites) a file with the given text, in UTF-8 without BOM
File.WriteAllText(path, "Angers\nNantes\n");
// File.AppendAllLines: adds lines at the end of the file
File.AppendAllLines(path, ["Paris", "Lyon"]);
// File.ReadAllLines: all the lines in an array
string[] lines = File.ReadAllLines(path);
Console.WriteLine($"ReadAllLines : {lines.Length} lines [{string.Join(", ", lines)}]");
// File.ReadAllText: the whole file in a string
Console.WriteLine($"ReadAllText : {File.ReadAllText(path).Length} characters");
// File.ReadLines: the lines are read one by one, on demand (IEnumerable<string>)
foreach (string city in File.ReadLines(path).Where(l => l.StartsWith('N') || l.StartsWith('L')))
{
    Console.WriteLine($"ReadLines + Where : {city}");
}

// the encoding: UTF-8 without BOM by default; another one can be imposed
string text = "café";
File.WriteAllText(Path.Combine(folder, "utf8.txt"), text);
File.WriteAllText(Path.Combine(folder, "utf8bom.txt"), text, new UTF8Encoding(true));
File.WriteAllText(Path.Combine(folder, "latin1.txt"), text, Encoding.Latin1);
foreach (string name in new[] { "utf8.txt", "utf8bom.txt", "latin1.txt" })
{
    byte[] bytes = File.ReadAllBytes(Path.Combine(folder, name));
    Console.WriteLine($"{name,-12}: {bytes.Length} bytes: {Convert.ToHexString(bytes)}");
}
// a Latin-1 file read back as UTF-8: the accented characters are lost
Console.WriteLine($"latin1.txt read as UTF-8: {File.ReadAllText(Path.Combine(folder, "latin1.txt"))}");
Console.WriteLine($"latin1.txt read as Latin-1: {File.ReadAllText(Path.Combine(folder, "latin1.txt"), Encoding.Latin1)}");

// File.Exists, Directory.GetFiles
Console.WriteLine($"Exists(cities.txt)={File.Exists(path)}, Exists(xx.txt)={File.Exists(Path.Combine(folder, "xx.txt"))}");
foreach (string file in Directory.GetFiles(folder, "*.txt").Order())
{
    Console.WriteLine($"  {Path.GetFileName(file),-12} {new FileInfo(file).Length,3} bytes");
}

// cleanup: deletion of the folder and its content
Directory.Delete(folder, recursive: true);
Console.WriteLine($"after Delete: Directory.Exists={Directory.Exists(folder)}");

Results:

> dotnet run files-03.cs
path=data/cities.txt, name=cities.txt, without extension=cities, extension=.txt
ReadAllLines : 4 lines [Angers, Nantes, Paris, Lyon]
ReadAllText : 25 characters
ReadLines + Where : Nantes
ReadLines + Where : Lyon
utf8.txt    : 5 bytes: 636166C3A9
utf8bom.txt : 8 bytes: EFBBBF636166C3A9
latin1.txt  : 4 bytes: 636166E9
latin1.txt read as UTF-8: caf�
latin1.txt read as Latin-1: café
Exists(cities.txt)=True, Exists(xx.txt)=False
  cities.txt    25 bytes
  latin1.txt     4 bytes
  utf8.txt       5 bytes
  utf8bom.txt    8 bytes
after Delete: Directory.Exists=False

Comments:

  • Line 7: [Path.Combine] uses the system's path separator. The course exercises were run on Linux: the resulting path is [data/cities.txt]; on Windows, it would be [data\cities.txt]. Constructing paths using [Path.Combine] rather than concatenating strings makes the code portable;
  • line 11: creating the folder [data] does not cause an error if it already exists;
  • Lines 14–18: writing, appending, and reading, each in a single statement;
  • line 23: [File.ReadLines] returns a sequence: the lines are read one by one as the loop progresses, like the elements of an iterator. You can apply LINQ to it, and thus process a file several gigabytes in size without loading it into memory—which is what [ReadAllLines] would do. The file is closed at the end of the iteration;
  • lines 29–36: the same text “café” is written using three encodings, then the bytes of the files are displayed in hexadecimal:
    • In UTF-8, the default encoding of .NET, accented characters take up 2 bytes (é = C3 A9) and the others 1 byte: 5 bytes for 4 characters;
    • in UTF-8 with BOM (Byte Order Mark), the file begins with the 3 bytes EF BB BF, which indicate to software that the file is encoded in UTF-8. [new UTF8Encoding(true)] instructs the system to write this BOM;
    • in Latin-1 (ISO-8859-1), the traditional encoding for Western European texts, each character occupies one byte (é = E9);
  • Line 39: Reading a file with an encoding different from the one used to write it alters accented characters: the byte E9, at the end of the file, is invalid in UTF-8, and each invalid byte is replaced by the character �. When reading, [File.ReadAllText] and [StreamReader] recognize BOM if it is present; otherwise, they assume UTF-8, unless another encoding is specified (line 40). Today, the rule is simple: text files are written and read in UTF-8;
  • line 44: the names of the text files in the folder. [GetFiles] does not guarantee the order of the names; the method LINQ [Order] (.NET 7) sorts them;
  • line 50: Deletes the folder and its contents. The parameter is passed by name, [recursive: true], which makes the call more readable than a simple [true] (named arguments, Chapter 3).

All of these methods have asynchronous versions ([File.ReadAllTextAsync], [File.ReadAllLinesAsync]…): we’ll use them in Chapter 9, which is devoted to asynchronous programming.

5.7. Binary Files

5.7.1. The classes BinaryWriter and BinaryReader

The classes [System.IO.BinaryWriter] and [System.IO.BinaryReader] are used to write and read binary files. In such a file, data is not written as text but in its internal form: a [int] always occupies 4 bytes, regardless of its value.

Consider the following application:

1
2
3
4
// syntax: dotnet run files-04.cs -- text binary logs
// we read a text file (text) and store its content in a binary file (binary)
// the text file has lines of the form name : age which we write in the form (string, int)
// (logs) is a text log file

The text file [persons1.txt] has the following content:

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

jacques : 11
sylvain : 12
xx : -1

xx: yy : zz
xx : yy

The program [files-04.cs] is as follows:

// syntax: dotnet run files-04.cs -- text binary logs
// we read a text file (text) and store its content in a binary file (binary)
// the text file has lines of the form name : age which we write in the form (string, int)
// (logs) is a text log file

// 3 arguments are needed
if (args.Length != 3)
{
    Console.WriteLine("syntax: dotnet run files-04.cs -- text binary logs");
    return 1;
}
try
{
    // opening the text file for reading
    using var input = new StreamReader(args[0]);
    // opening the binary file for writing
    using var output = new BinaryWriter(new FileStream(args[1], FileMode.Create, FileAccess.Write));
    // opening the log file for writing
    using var logs = new StreamWriter(args[2]);
    // processing the text file
    string? line;
    int lineNumber = 0, nbPersons = 0;
    while ((line = input.ReadLine()) != null)
    {
        // one more line
        lineNumber++;
        // empty line? we ignore it
        if (line.Trim() == "")
        {
            continue;
        }
        // a name : age line
        string[] fields = line.Split(':', StringSplitOptions.TrimEntries);
        // we need 2 fields
        if (fields.Length != 2)
        {
            logs.WriteLine($"Line no. [{lineNumber}] of the file [{args[0]}] has an incorrect number of fields");
            continue;
        }
        // the 1st field must not be empty
        bool error = false;
        string name = fields[0];
        if (name == "")
        {
            logs.WriteLine($"Line no. [{lineNumber}] of the file [{args[0]}] has an empty name");
            error = true;
        }
        // the second field must be an integer >= 0
        if (!int.TryParse(fields[1], out int age) || age < 0)
        {
            logs.WriteLine($"Line no. [{lineNumber}] of the file [{args[0]}] has an incorrect age [{fields[1]}]");
            error = true;
        }
        // if no error, we write the data into the binary file
        if (!error)
        {
            output.Write(name);
            output.Write(age);
            nbPersons++;
        }
    }
    Console.WriteLine($"{lineNumber} lines read, {nbPersons} persons written to [{args[1]}], errors in [{args[2]}]");
    // end of the try block: the three streams are closed here, in the reverse order of their opening
}
catch (FileNotFoundException e)
{
    // e.FileName is the full path of the file; we only display its name
    Console.WriteLine($"The file [{Path.GetFileName(e.FileName)}] does not exist");
    return 2;
}
catch (IOException e)
{
    Console.WriteLine($"The following error occurred: {e.GetType().Name} : {e.Message}");
    return 3;
}
return 0;

Let’s take a closer look at the operations involving the class [BinaryWriter]:

  • Line 17: The object [BinaryWriter] is opened by the operation [new BinaryWriter(new FileStream(args[1], FileMode.Create, FileAccess.Write))]. The constructor’s argument must be a stream ([Stream]). Here, it is a stream created from a file ([FileStream]), for which we specify:
    • the name;
    • the operation to be performed, in this case [FileMode.Create] to create the file (or overwrite it if it exists);
    • the access type, in this case [FileAccess.Write] for write access to the file;
  • lines 57–58: the write operations. The [BinaryWriter] class has overloaded [Write] methods for writing the various types of simple data ([int], [double], [decimal], [bool], [string]...);
  • lines 15–19: The three streams are opened with [using] declarations. They will be closed at the end of the [try] block, in the reverse order in which they were opened. The 2008 course closed them in a [finally] clause after checking that they were not [null]: the [using] declaration does the same thing in a single line;
  • line 10: high-level instructions can end with a [return n]; n is then the program’s exit code, which a script can check (0: success);
  • line 33: the option [TrimEntries] removes spaces around the name and age;
  • line 49: the age must be a positive integer or zero. Thanks to the short-circuit behavior of the [||] operator, the condition [age < 0] is evaluated only if the conversion was successful.

The program’s three arguments are passed after [--] in the [dotnet run] command. The results are as follows:

> dotnet run files-04.cs -- persons1.txt persons1.bin logs.txt
9 lines read, 4 persons written to [persons1.bin], errors in [logs.txt]

The log file [logs.txt] contains the following:

1
2
3
4
> cat logs.txt
Line no. [6] of the file [persons1.txt] has an incorrect age [-1]
Line no. [8] of the file [persons1.txt] has an incorrect number of fields
Line no. [9] of the file [persons1.txt] has an incorrect age [yy]

The contents of the binary file [persons1.bin] can be displayed in hexadecimal using the Linux command [od] (on Windows, the command PowerShell [Format-Hex] produces a similar display):

1
2
3
4
5
> od -A d -t x1z persons1.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

This shows how [BinaryWriter] writes the data:

  • a string is written preceded by its length in bytes: the byte 04 followed by the 4 bytes of “paul” (70 61 75 6c);
  • an integer [int] is written over 4 bytes, with the least significant byte first: 10 is written as 0a 00 00 00;
  • the data is written one after another, without any separators or line breaks: the file is 44 bytes long (line 5: with the option [-A d], [od] displays the positions in decimal).

To read this file, you must therefore know the exact order and type of the data that was written. This is what the program [files-05.cs] does; it performs the reverse operation: it reads the binary file to create a text file. If the resulting text file contains the correct data from the original file, you will know that the text → binary → text conversion was successful.

// syntax: dotnet run files-05.cs -- binary text
// we read a binary file (binary) and store its content in a text file (text)
// the binary file contains (string, int) pairs
// the text file will have lines of the form name:age

// 2 arguments are needed
if (args.Length != 2)
{
    Console.WriteLine("syntax: dotnet run files-05.cs -- binary text");
    return 1;
}
try
{
    // opening the binary file for reading
    using var input = new BinaryReader(new FileStream(args[0], FileMode.Open, FileAccess.Read));
    // opening the text file for writing
    using var output = new StreamWriter(args[1]);
    // processing the binary file, as long as we are not at the end of the stream
    int nbPersons = 0;
    while (input.BaseStream.Position < input.BaseStream.Length)
    {
        // reading name, then age: in the order in which they were written
        string name = input.ReadString();
        int age = input.ReadInt32();
        // writing into the text file
        output.WriteLine($"{name}:{age}");
        nbPersons++;
    }
    Console.WriteLine($"{nbPersons} persons read from [{args[0]}] and written to [{args[1]}]");
}
catch (EndOfStreamException)
{
    // the file ends in the middle of a record
    Console.WriteLine($"The file [{args[0]}] is truncated");
    return 2;
}
catch (FileNotFoundException e)
{
    // e.FileName is the full path of the file; we only display its name
    Console.WriteLine($"The file [{Path.GetFileName(e.FileName)}] does not exist");
    return 3;
}
catch (IOException e)
{
    Console.WriteLine($"The following error occurred: {e.GetType().Name} : {e.Message}");
    return 4;
}
return 0;

Let’s take a closer look at the operations involving the [BinaryReader] class:

  • line 15: the [BinaryReader] object is opened on a [FileStream] stream using the [FileMode.Open] mode (to open an existing file) and the [FileAccess.Read] access mode (for read-only access);
  • Lines 23–24: Read operations. The [BinaryReader] class provides the [ReadXX] methods to read various types of simple data: [ReadString], [ReadInt32], [ReadDouble], [ReadDecimal], [ReadBoolean]... These must be called in the order of the writes;
  • Line 20: The property [BaseStream] provides the underlying stream, in this case [FileStream]. Reading continues until the current position in the file reaches the end of the file. The 2008 version read indefinitely and detected the end of the file via the [EndOfStreamException] exception: an exception must remain exceptional, and the end of a file is not;
  • line 31: this exception now signals a true anomaly: a file that ends in the middle of a record.

Let’s run the program on the [persons1.bin] file generated earlier:

> dotnet run files-05.cs -- persons1.bin persons2.txt
4 persons read from [persons1.bin] and written to [persons2.txt]

The contents of [persons2.txt] are as follows:

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

We find the 4 valid people from the [persons1.txt] file. With a [truncated.bin] file consisting of the first 30 bytes of [persons1.bin], reading Jacques’s age fails:

> dotnet run files-05.cs -- truncated.bin truncated.txt
The file [truncated.bin] is truncated

5.7.2. The JSON serialization

Writing and reading a binary file byte by byte is tedious, and even the slightest discrepancy between writing and reading produces incorrect results. It is generally preferable to serialize objects: automatically convert them into a sequence of bytes or characters, then deserialize them to reconstruct them.

.NET provided the [BinaryFormatter] class for this purpose, which serialized objects into a binary format specific to .NET. It has now been removed: it was declared obsolete in .NET 5, and its implementation was removed in .NET 9, because deserializing data from an untrusted source with [BinaryFormatter] allowed arbitrary code to be executed. Microsoft recommends replacing it with the JSON serialization from the [System.Text.Json] library, which has been integrated into .NET since .NET Core 3.0.

JSON (JavaScript Object Notation) is a text-based, human-readable, and universal format: it is the exchange format for web services (Chapter 13) and numerous configuration files. An object is written within curly braces in the form of "property":value pairs, with arrays enclosed in square brackets.

The static class [System.Text.Json.JsonSerializer] has two main methods:

method
role
[string Serialize<T>(T value, JsonSerializerOptions? options = null)]
returns the text JSON representing the object [value]
[T? Deserialize<T>(string json, JsonSerializerOptions? options = null)]
reconstructs an object of type [T] from the text JSON [json]; throws a [JsonException] if the text is incorrect

The program [files-06.cs] uses them:

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

// JSON serialization with System.Text.Json

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

// 1 - default serialization: a single line, non-ASCII characters escaped
string json = JsonSerializer.Serialize(persons[1]);
Console.WriteLine($"[default] {json}");

// 2 - options: indentation, accented characters left as they are
var options = new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};
json = JsonSerializer.Serialize(persons, options);
// writing into a text file
File.WriteAllText("persons.json", json);
Console.WriteLine("[persons.json]");
Console.WriteLine(File.ReadAllText("persons.json"));

// 3 - deserialization: from JSON text to objects
string text = File.ReadAllText("persons.json");
List<Person>? readBack = JsonSerializer.Deserialize<List<Person>>(text);
Console.WriteLine($"[read back] {readBack?.Count} persons");
foreach (Person p in readBack ?? [])
{
    Console.WriteLine($"  {p.Name}, {p.Age} years old, hobbies: [{string.Join(", ", p.Hobbies)}]");
}

// 4 - without attributes: the "Web" options put the property names in camelCase
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
string jsonArticle = JsonSerializer.Serialize(new Article("A12", 12.5m), web);
Console.WriteLine($"[Web options] {jsonArticle}");
// and they ignore case when reading
Article? article = JsonSerializer.Deserialize<Article>("""{ "CODE": "B7", "price": 3 }""", web);
Console.WriteLine($"[Web options] {article}");

// 5 - an incorrect JSON document causes a JsonException
try
{
    JsonSerializer.Deserialize<Person>("""{ "name": "x", "age": "twelve" }""");
}
catch (JsonException e)
{
    Console.WriteLine($"[error] {e.GetType().Name} : {e.Message}");
}

// a person; the [JsonPropertyName] attributes set the names of the JSON properties
record Person(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("age")] int Age,
    [property: JsonPropertyName("hobbies")] List<string> Hobbies);

// an article, without attributes
record Article(string Code, decimal Price);

Results:

> dotnet run files-06.cs
[default] {"name":"h\u00E9l\u00E8ne","age":15,"hobbies":["piano","tennis"]}
[persons.json]
[
  {
    "name": "paul",
    "age": 10,
    "hobbies": [
      "football"
    ]
  },
  {
    "name": "hélène",
    "age": 15,
    "hobbies": [
      "piano",
      "tennis"
    ]
  },
  {
    "name": "jacques",
    "age": 11,
    "hobbies": []
  }
]
[read back] 3 persons
  paul, 10 years old, hobbies: [football]
  hélène, 15 years old, hobbies: [piano, tennis]
  jacques, 11 years old, hobbies: []
[Web options] {"code":"A12","price":12.5}
[Web options] Article { Code = B7, Price = 3 }
[error] JsonException : The JSON value could not be converted to Person. Path: $.age | LineNumber: 0 | BytePositionInLine: 30.

Comments:

  • Line 1: A directive for single-file applications. By default, these are configured for native compilation (AOT, "ahead of time"), which produces a standalone executable but disables mechanisms that rely on runtime type discovery (reflection). However, [JsonSerializer] uses reflection by default to discover the properties of objects to be serialized: without this directive, compilation produces warnings and execution fails. A standard [.csproj] project, such as the one in the sample application, does not have this restriction. For a natively compiled application, [System.Text.Json] provides a source generator (a class derived from [JsonSerializerContext]) that generates the serialization code at compile time: we will not cover this here;
  • lines 60–63: the [Person] record to be serialized. Its properties carry the attribute [JsonPropertyName], which sets the property name in the text JSON to [name] instead of [Name]. In a positional record, [property: ...] indicates that the attribute applies to the generated property and not to the constructor parameter;
  • line 17: serialization of an object with default options: the text is written on a single line, and non-ASCII characters are escaped: [é] becomes [\u00E9]. The text remains correct, but it is difficult to read;
  • Lines 21–26: The option [WriteIndented] produces indented text across multiple lines; the option [Encoder] specifies which characters to leave as-is—in this case, all Unicode characters. A list is serialized into an array JSON;
  • line 28: the text JSON is saved to a text file: this is how binary serialization is replaced today;
  • line 34: deserialization reconstructs the list. [Deserialize] creates the [Person] objects by calling the record’s constructor, whose parameters are associated with the JSON properties of the same name. The result is of type [List<Person>?]: the text JSON [null] is a valid document that yields [null];
  • line 42: the “Web” options—which are used by default by ASP.NET Core’s web services—write property names in camelCase format (first letter lowercase): [Code] becomes [code], without an attribute. When parsed, they are case-insensitive: [CODE] is accepted (line 31);
  • line 52: an invalid JSON document—in this case, a string where a number is expected—triggers a [JsonException] exception, whose message indicates the location of the error.

We will use JSON in the following example application and throughout the course.

5.8. Regular Expressions

The [System.Text.RegularExpressions.Regex] class supports the use of regular expressions. These allow you to validate the format of a string. For example, you can verify that a string representing a date is indeed in the dd/mm/yy format. To do this, you use a pattern and compare the string to that pattern. In this example, d, m, and y must be digits. The pattern for a valid date format is therefore [\d\d/\d\d/\d\d], where the symbol [\d] represents a digit. The symbols that can be used in a pattern are as follows:

character
description
[\]
marks the following character as a special character or literal. For example, [n] corresponds to the character n, [\n] corresponds to a newline character, [\\] corresponds to \, and [\(] corresponds to (
[^]
corresponds to the beginning of the string
[$]
corresponds to the end of the string
[*]
matches the preceding element zero or more times. Thus, [zo*] matches z or zoo
[+]
matches the previous element one or more times. Thus, [zo+] matches "zoo," but not "z"
[?]
matches the preceding element zero or one time. For example, [a?ve?] matches “ve” in “lever”
[.]
matches any single character, except for a newline character
[(pattern)]
searches for the pattern and stores the match (a group). The matching substring can be extracted from the [Groups] collection in the result. To find matches with characters in parentheses, use [\(] or [\)]
[x|y]
matches either x or y. For example, [z|foot] matches z or foot; [(z|f)oo] matches zoo or foo
[{n}]
n is a non-negative integer. Corresponds exactly n times to the previous element. For example, [o{2}] does not correspond to o in Bob, but to the first two o’s in fooooot
[{n,}]
matches the previous element at least n times. For example, [o{2,}] does not match the 'o' in 'Bob', but matches all the 'o's in 'fooooot'. [o{1,}] is equivalent to [o+], and [o{0,}] is equivalent to [o*]
[{n,m}]
matches the previous element at least n times and at most m times. For example, [o{1,3}] matches the first three “o”s in “foooooot”; [o{0,1}] is equivalent to [o?]
[xyz]
character set: matches one of the specified characters. For example, [abc] matches the letter “a” in “plat”
[^xyz]
negative character set: matches any character not listed. For example, [^abc] matches "p" in "plat"
[a-z]
Character range: matches any character in the specified range, in this case a lowercase unaccented letter
[^m-z]
negative character range: matches any character not between 'm' and 'z'
[\b]
matches a word boundary, that is, the position between a word character and a non-word character. For example, [er\b] matches "er" in "lever," but not "er" in "verb"
[\B]
corresponds to a position that is not a word boundary. [en*t\B] corresponds to “ent” in “entertain”
[\d]
corresponds to a digit; is equivalent to [0-9] (along with .NET, also for digits from other Unicode alphabets)
[\D]
corresponds to a character that is not a digit
[\f], [\n], [\r], [\t], [\v]
correspond to the characters page break, new line, carriage return, tab, and vertical tab, respectively
[\s]
corresponds to any whitespace: space, tab, page break, line break… Equivalent to [ \f\n\r\t\v], plus other Unicode whitespace characters (non-breaking space, etc.)
[\S]
matches any character that is not a whitespace character
[\w]
matches any word character: letter (with or without accents), digit, or underscore
[\W]
matches any character that is not a word character
[\num]
num is a positive integer: refers to group number num. For example, [(.)\1] matches two consecutive identical characters
[\xnn], [\unnnn]
corresponds to the hexadecimal character nn (2 digits) or nnnn (4 digits). For example, [\x41] corresponds to A

An element in a template can appear once or multiple times. Let’s look at a few examples using the symbol [\d], which represents a single digit:

template
Meaning
[\d]
one digit
[\d?]
0 or 1 digit
[\d*]
0 or more digits
[\d+]
1 or more digits
[\d{2}]
2 digits
[\d{3,}]
At least 3 digits
[\d{5,7}]
between 5 and 7 digits

Now let’s imagine a model capable of describing the expected format for a string:

target string
pattern
a date in dd/mm/yy format
[\d{2}/\d{2}/\d{2}]
a time in the hh:mm:ss format
[\d{2}:\d{2}:\d{2}]
an unsigned integer
[\d+]
a sequence of spaces, which may be empty
[\s*]
an unsigned integer that may be preceded or followed by spaces
[\s*\d+\s*]
an integer that may be signed and preceded or followed by spaces
[\s*[+-]?\s*\d+\s*]
an unsigned real number that may be preceded or followed by spaces
[\s*\d+(\.\d*)?\s*]
a real number that may be signed and preceded or followed by spaces
[\s*[+-]?\s*\d+(\.\d*)?\s*]
a string containing the word "just"
[\bjust\b]

The 2008 course used [+|-] for the sign: enclosed in square brackets, the character [|] has no specific meaning, and this format also accepted the | sign before a number. It also used [(.\d*)] for the decimal part: the unescaped period there represents any character. The patterns above have been corrected.

You can specify where to look for the pattern in the string:

pattern
meaning
[^pattern]
the pattern begins the string
[pattern$]
The pattern ends the string
[^pattern$]
The pattern starts and ends the string
[pattern]
The pattern is searched for anywhere in the string, starting from the beginning of the string
search string
pattern
a string ending with an exclamation point
[!$]
a string ending with a period
[\.$]
a string beginning with the sequence //
[^//]
a string consisting of a single word, possibly preceded or followed by spaces
[^\s*\w+\s*$]
a string consisting of exactly two words, possibly preceded or followed by spaces
[^\s*\w+\s+\w+\s*$]
a string containing the word "secret"
[\bsecret\b]

Sub sets of a pattern can be “extracted.” Thus, not only can we verify that a string matches a particular pattern, but we can also extract from that string the elements corresponding to the pattern’s sub sets that have been enclosed in parentheses. For example, if you are parsing a string containing a date in the dd/mm/yy format and also want to extract the dd, mm, and yy components of that date, you would use the pattern [(\d\d)/(\d\d)/(\d\d)].

In C# code, a pattern is preferably written as a verbatim string [@"..."]: this way, the numerous [\] characters in the patterns do not need to be doubled. With a regular string, you would have to write ***["\\\\d+"] instead of ***[@"\\d+"].

5.8.1. Check whether a string matches a given template

An object of type [Regex] is constructed as follows:

constructor
role
[public Regex(string pattern)]
creates a “regular expression” object from a pattern passed as a parameter ([pattern]). A [ArgumentException] exception is thrown if the pattern is incorrect
[public Regex(string pattern, RegexOptions options, TimeSpan matchTimeout)]
same as above, with options and a timeout (see below)

Once the regular expression has been constructed, it can be compared to character strings using the [IsMatch] method:

method
role
[public bool IsMatch(string input)]
true if the string [input] contains a substring that matches the regular expression pattern

The program [regex-01.cs] is as follows:

using System.Text.RegularExpressions;

// checking that a string matches a pattern
string pattern1 = @"^\s*\d+\s*$";
var regex1 = new Regex(pattern1);
// comparing samples with the pattern
foreach (string sample in new[] { "  123  ", "  123a  ", "4567" })
{
    if (regex1.IsMatch(sample))
    {
        Console.WriteLine($"[{sample}] matches the pattern [{pattern1}]");
    }
    else
    {
        Console.WriteLine($"[{sample}] does not match the pattern [{pattern1}]");
    }
}
// the static method Regex.IsMatch avoids creating the Regex object yourself
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}$")}");

Results:

1
2
3
4
5
6
> dotnet run regex-01.cs
[  123  ] matches the pattern [^\s*\d+\s*$]
[  123a  ] does not match the pattern [^\s*\d+\s*$]
[4567] matches the pattern [^\s*\d+\s*$]
Regex.IsMatch("18/05/97", date) : True
Regex.IsMatch("5/1/01", date) : False

Comments:

  • line 4: the pattern for an unsigned integer, optionally surrounded by spaces, that occupies the entire string (anchors [^] and [$]);
  • line 9: the string “123a,” surrounded by spaces, does not match the pattern: the character “a” cannot be a digit or a space. Without the anchors [^] and [$], the string would match, since it contains a number;
  • line 19: The class [Regex] also has static methods that take the pattern as a parameter. .NET stores the most recently used patterns in memory: they can be used in a loop without worrying that the pattern will be parsed on every call.

5.8.2. Find all occurrences of a pattern in a string

The [Matches] method retrieves the elements of a string that match a pattern:

method
role
[public MatchCollection Matches(string input)]
returns the collection of elements in the string [input] that match the pattern

The [MatchCollection] class has a property, [Count], which is the number of elements in the collection. If [results] is a [MatchCollection] object, then [results[i]] is the i-th element of that collection; it is of type [Match]. The class [Match] has various properties, including the following:

  • [Value]: the value of the object [Match], i.e., an element matching the pattern;
  • [Index]: the position where the element was found in the explored string.

The program [regex-02.cs] is as follows:

using System.Text.RegularExpressions;

// finding all the occurrences of a pattern in a string
string pattern2 = @"\d+";
var regex2 = new Regex(pattern2);
string sample3 = "  123  456  789 ";
MatchCollection results = regex2.Matches(sample3);
Console.WriteLine($"Pattern=[{pattern2}], sample=[{sample3}]");
Console.WriteLine($"There are {results.Count} occurrences of the pattern in the sample");
foreach (Match m in results)
{
    Console.WriteLine($"[{m.Value}] found at position {m.Index}");
}
// a MatchCollection is a sequence: LINQ applies to it
int sum = results.Sum(m => int.Parse(m.Value));
Console.WriteLine($"sum of the numbers found: {sum}");
// Count counts the occurrences without building the collection
Console.WriteLine($"number of vowels of \"antidisestablishmentarianism\": {Regex.Count("antidisestablishmentarianism", "[aeiouy]")}");

Results:

1
2
3
4
5
6
7
8
> dotnet run regex-02.cs
Pattern=[\d+], sample=[  123  456  789 ]
There are 3 occurrences of the pattern in the sample
[123] found at position 2
[456] found at position 7
[789] found at position 12
sum of the numbers found: 1368
number of vowels of "antidisestablishmentarianism": 11

Comments:

  • Line 4: The pattern being searched for is a sequence of digits;
  • line 6: the string in which this pattern is searched for;
  • line 7: all elements of [sample3] that match the pattern [pattern2] are retrieved;
  • Lines 10–12: These are displayed. A [MatchCollection] is traversed with [foreach], like a list;
  • line 15: [MatchCollection] implements [IEnumerable<Match>]: LINQ is applied to it. Here, we sum the numbers found;
  • line 18: the method [Count] (.NET 7) counts the occurrences of the pattern without constructing a collection. The pattern [aeiouy] denotes a vowel.

5.8.3. Retrieving Parts of a Pattern

As mentioned earlier, the parts of a pattern enclosed in parentheses are stored: these are the groups. The program [regex-03.cs] uses them:

using System.Text.RegularExpressions;

// retrieving parts of a pattern: groups
string pattern3 = @"(\d\d):(\d\d):(\d\d)";
var regex3 = new Regex(pattern3);
string sample4 = "It is 18:05:49";
// Match returns the first occurrence of the pattern
Match result = regex3.Match(sample4);
if (result.Success)
{
    Console.WriteLine($"The sample [{sample4}] matches the pattern [{pattern3}]");
    // we display the parenthesized groups
    for (int i = 0; i < result.Groups.Count; i++)
    {
        Console.WriteLine($"groups[{i}]=[{result.Groups[i].Value}] found at position {result.Groups[i].Index}");
    }
}
else
{
    Console.WriteLine($"The sample [{sample4}] does not match the pattern [{pattern3}]");
}

// named groups: (?<name>...)
var regexDate = new Regex(@"(?<day>\d{1,2})/(?<month>\d{1,2})/(?<year>\d{4})");
string text = "Appointment on 3/10/2026, then on 15/11/2026.";
foreach (Match m in regexDate.Matches(text))
{
    // a group is designated by its name rather than by its number
    int day = int.Parse(m.Groups["day"].Value);
    int month = int.Parse(m.Groups["month"].Value);
    int year = int.Parse(m.Groups["year"].Value);
    var date = new DateOnly(year, month, day);
    Console.WriteLine($"[{m.Value}] : day={day}, month={month}, year={year} -> {date:dddd d MMMM yyyy}");
}

// Replace: the groups can be reused in the replacement string
string reversed = regexDate.Replace(text, "${year}-${month}-${day}");
Console.WriteLine($"Replace : {reversed}");

Results:

1
2
3
4
5
6
7
8
9
> dotnet run regex-03.cs
The sample [It is 18:05:49] matches the pattern [(\d\d):(\d\d):(\d\d)]
groups[0]=[18:05:49] found at position 6
groups[1]=[18] found at position 6
groups[2]=[05] found at position 9
groups[3]=[49] found at position 12
[3/10/2026] : day=3, month=10, year=2026 -> Saturday 3 October 2026
[15/11/2026] : day=15, month=11, year=2026 -> Sunday 15 November 2026
Replace : Appointment on 2026-10-3, then on 2026-11-15.

Comments:

  • Line 8: The string [sample4] is compared to the template [regex3] using the method [Match]. This method returns the first occurrence of the template as a [Match] object, which has already been presented. Here, we use two new properties of this class:
    • [Success] (line 9): indicates whether a match was found;
    • [Groups] (line 13): a collection where [Groups[0]] corresponds to the part of the string representing the template, and [Groups[i]] (i >= 1) corresponds to the i-th set of parentheses;
  • if [result] is of type [Match], then [result.Groups] is of type [GroupCollection] and [result.Groups[i]] is of type [Group]. The class [Group] has, like the class [Match], the properties [Value] (the text captured by the group) and [Index] (its position in the parsed string);
  • line 24: a group can be named using the syntax [(?<name>...)]. Here, the pattern recognizes a date in the d/m/yyyy format, with a one- or two-digit day and month;
  • lines 29–31: a named group is referred to by its name rather than its number: the code is more readable and does not depend on the order of the parentheses;
  • line 32: the type [DateOnly] (.NET 6) represents a date without a time. It is displayed in the format [dddd d MMMM yyyy]: the day of the week appears in English;
  • line 37: the [Replace] method replaces each occurrence of the template with a string in which [${name}] (or [$1], [$2]… for numbered groups) denotes the value of a group. Dates are thus reformatted into the year-month-day format.

5.8.4. A Learning Program

Finding the regular expression that verifies whether a string matches a certain pattern can sometimes be a real challenge. The program [regex-04.cs] lets you practice. It takes a pattern and a string as input and indicates whether the string matches the pattern or not.

using System.Text.RegularExpressions;

// a program for learning regular expressions
while (true)
{
    // we ask for the pattern
    Console.Write("Type the pattern to test or nothing to stop: ");
    string pattern = Console.ReadLine() ?? "";
    // finished?
    if (pattern.Trim() == "")
    {
        break;
    }
    // we create the regular expression; an incorrect pattern causes an exception
    Regex regex;
    try
    {
        regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1));
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"Error: {e.Message}");
        continue;
    }
    // we ask for the samples to compare with the pattern
    while (true)
    {
        Console.Write($"Type the string to compare with the pattern [{pattern}] or nothing to stop: ");
        string input = Console.ReadLine() ?? "";
        // finished?
        if (input.Trim() == "")
        {
            break;
        }
        try
        {
            // we do the comparison
            MatchCollection results = regex.Matches(input);
            if (results.Count == 0)
            {
                Console.WriteLine("I did not find any matches");
                continue;
            }
            // we display the elements matching the pattern
            foreach (Match m in results)
            {
                Console.WriteLine($"I found the match [{m.Value}] at position [{m.Index}]");
                // and its sub-elements (groups 1, 2, ...)
                for (int j = 1; j < m.Groups.Count; j++)
                {
                    Console.WriteLine($"\tsub-element [{m.Groups[j].Value}] at position [{m.Groups[j].Index}]");
                }
            }
        }
        catch (RegexMatchTimeoutException)
        {
            Console.WriteLine("The search was abandoned: it lasted more than one second");
        }
    }
}

Comments:

  • line 18: The regular expression is constructed with a one-second timeout: if a search takes longer than that, it is abandoned and a [RegexMatchTimeoutException] exception is thrown (line 55). We’ll see later why this is useful;
  • line 20: an invalid pattern causes a [ArgumentException] exception (more specifically, [RegexParseException], which is derived from it);
  • lines 45–51: For each match, groups #1 and subsequent groups are displayed, if any.

Here is an example of the output:

> dotnet run regex-04.cs
Type the pattern to test or nothing to stop: \d+
Type the string to compare with the pattern [\d+] or nothing to stop: 123 456 789
I found the match [123] at position [0]
I found the match [456] at position [4]
I found the match [789] at position [8]
Type the string to compare with the pattern [\d+] or nothing to stop: 
Type the pattern to test or nothing to stop: (\d{2}):(\d\d)
Type the string to compare with the pattern [(\d{2}):(\d\d)] or nothing to stop: 14:15 abcd 17:18 xyzt
I found the match [14:15] at position [0]
    sub-element [14] at position [0]
    sub-element [15] at position [3]
I found the match [17:18] at position [11]
    sub-element [17] at position [11]
    sub-element [18] at position [14]
Type the string to compare with the pattern [(\d{2}):(\d\d)] or nothing to stop: 
Type the pattern to test or nothing to stop: ^\s*\d+\s*$
Type the string to compare with the pattern [^\s*\d+\s*$] or nothing to stop:    1456
I found the match [   1456] at position [0]
Type the string to compare with the pattern [^\s*\d+\s*$] or nothing to stop: 
Type the pattern to test or nothing to stop: ^\s*(\d+)\s*$
Type the string to compare with the pattern [^\s*(\d+)\s*$] or nothing to stop: 1456
I found the match [1456] at position [0]
    sub-element [1456] at position [0]
Type the string to compare with the pattern [^\s*(\d+)\s*$] or nothing to stop: abcd 1456
I did not find any matches
Type the string to compare with the pattern [^\s*(\d+)\s*$] or nothing to stop: 
Type the pattern to test or nothing to stop: (a+)+$
Type the string to compare with the pattern [(a+)+$] or nothing to stop: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
The search was abandoned: it lasted more than one second
Type the string to compare with the pattern [(a+)+$] or nothing to stop: 
Type the pattern to test or nothing to stop: (\d+
Error: Invalid pattern '(\d+' at offset 4. Not enough )'s.
Type the pattern to test or nothing to stop: 

The templates [^\s*\d+\s*$] and [^\s*(\d+)\s*$] demonstrate the usefulness of groups: the first recognizes the string “1456” preceded by three spaces, including the spaces; the second additionally extracts the number without the spaces. The [(a+)+$] template is examined below.

5.8.5. The Split Method

We have already encountered the [Split] method from the [String] class:

method
role
[public string[] Split(params char[] separator)]
The string is treated as a sequence of fields separated by the characters listed in the array [separator]. The result is an array of these fields

The method [Split] of the class [Regex] allows us to specify the separator using a pattern:

method
role
[public string[] Split(string input)]
The string [input] is split into fields, which are separated by a separator corresponding to the pattern of the current [Regex] object

Suppose, for example, that a text file contains lines of the form field1, field2, ..., fieldn. The fields are separated by a comma, but this comma may be preceded or followed by spaces. The [\s*,\s*] template describes this separator exactly, as shown by the [regex-05.cs] program:

using System.Text.RegularExpressions;

// Regex.Split: the separator is defined by a pattern
string line = "abc  , def  ,ghi ,   jkl";
// a comma preceded and followed by any number of spaces
var separator = new Regex(@"\s*,\s*");
string[] fields = separator.Split(line);
for (int i = 0; i < fields.Length; i++)
{
    Console.WriteLine($"fields[{i}]=[{fields[i]}]");
}
// several possible separators: ; or | or a sequence of spaces
string[] words = Regex.Split("one;two|three   four", @"[;|]|\s+");
Console.WriteLine($"words=[{string.Join(", ", words)}]");

Results:

1
2
3
4
5
6
> dotnet run regex-05.cs
fields[0]=[abc]
fields[1]=[def]
fields[2]=[ghi]
fields[3]=[jkl]
words=[one, two, three, four]

Comments:

  • line 6: the separator: a comma preceded and followed by any number of spaces. The spaces are removed along with the separator. Note that the option [TrimEntries] from [string.Split], presented above, now allows you to achieve the same result without a regular expression;
  • Line 13: A separator that can take several forms: a semicolon or a vertical bar ([;|]), or ([|]) a sequence of spaces ([\s+]). Here, a regular expression is required.

5.8.6. Generated regular expressions, options, timeout

A [Regex] object parses its template at runtime, then translates it into an internal structure that it interprets, or compiles into executable code if the [RegexOptions.Compiled] option is passed to it. Starting with .NET 7, the [GeneratedRegex] attribute allows this work to be done at compile time: a source generator writes the C# code for the search, which can be viewed in the editor (F12 on the method). The template is validated at compile time, there is no longer any analysis overhead at startup, and the generated code runs as fast as possible. This is now the recommended way to use a template known in advance. The program [regex-06.cs] is as follows:

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

// 1 - regular expressions generated at compile time
Console.WriteLine($"Time(\"It is 18:05:49\") : {Patterns.Time().IsMatch("It is 18:05:49")}");
Match m = Patterns.Time().Match("departure at 07:45:00");
Console.WriteLine($"hours={m.Groups["h"].Value}, minutes={m.Groups["m"].Value}, seconds={m.Groups["s"].Value}");

// 2 - options: here IgnoreCase, set in the attribute
foreach (string answer in new[] { "yes", "YES", "Yes", "no", "yesss" })
{
    Console.Write($"{answer}={Patterns.Yes().IsMatch(answer)} ");
}
Console.WriteLine();
// options are combined with |: Multiline makes ^ and $ match at each start and end of line
string text = "price: 10\nquantity: 3\ntotal: 30";
foreach (Match line in Regex.Matches(text, @"^(\w+): (\d+)$", RegexOptions.Multiline))
{
    Console.Write($"[{line.Groups[1].Value}={line.Groups[2].Value}] ");
}
Console.WriteLine();

// 3 - a timeout protects against patterns with catastrophic backtracking
string input = new string('a', 30) + "!";
try
{
    Console.WriteLine(Patterns.Catastrophic().IsMatch(input));
}
catch (RegexMatchTimeoutException e)
{
    Console.WriteLine($"{e.GetType().Name} : search abandoned after {e.MatchTimeout.TotalMilliseconds} ms");
}
// the same pattern with the non-backtracking engine answers immediately
var stopwatch = Stopwatch.StartNew();
bool found = Regex.IsMatch(input, @"^(a+)+$", RegexOptions.NonBacktracking);
Console.WriteLine($"NonBacktracking : {found}, answer in less than one second: {stopwatch.ElapsedMilliseconds < 1000}");

// the patterns of the program
static partial class Patterns
{
    // a time hh:mm:ss with named groups
    [GeneratedRegex(@"(?<h>\d\d):(?<m>\d\d):(?<s>\d\d)")]
    public static partial Regex Time();

    // yes, whatever the case
    [GeneratedRegex(@"^\s*yes\s*$", RegexOptions.IgnoreCase)]
    public static partial Regex Yes();

    // a pathological pattern, with a timeout of 100 ms
    [GeneratedRegex(@"^(a+)+$", RegexOptions.None, matchTimeoutMilliseconds: 100)]
    public static partial Regex Catastrophic();
}

Results:

1
2
3
4
5
6
7
> dotnet run regex-06.cs
Time("It is 18:05:49") : True
hours=07, minutes=45, seconds=00
yes=True YES=True Yes=True no=False yesss=False 
[price=10] [quantity=3] [total=30] 
RegexMatchTimeoutException : search abandoned after 100 ms
NonBacktracking : False, answer in less than one second: True

Comments:

  • Lines 39–51: Each template is declared by a partial static method ([partial]) with no body, which generates a [Regex], and is preceded by the attribute [GeneratedRegex]. The source generator writes the method body in another part of the class, which is also declared as [partial]. Starting with C# 13 and .NET 9, you can also declare a partial property: [[GeneratedRegex(...)] private static partial Regex Time { get; }];
  • line 5: the method is used as a regular [Regex] object;
  • line 46: the second parameter of the attribute is a combination of options [RegexOptions]. [IgnoreCase] ignores case: “YES” and “Yes” match the pattern;
  • line 17: the options are used in the same way with the methods of [Regex]. [Multiline] matches [^] and [$] at the beginning and end of each line in the string, rather than just at the beginning and end of the string itself. Other options are useful: [Singleline] ([.] also matches line breaks), [IgnorePatternWhitespace] (spaces in the pattern are ignored, allowing it to be written across multiple lines, including comments), [ExplicitCapture] (only named groups are remembered). These can be combined with the [|] operator;
  • line 50: the pattern [^(a+)+$] is pathological: when applied to a sequence of “a”s ending with another character, the regular expression engine tries every possible way to distribute the “a”s among the group repetitions before concluding that it has failed. The number of possibilities doubles with each additional “a”: with 30 “a”s, there are approximately one billion possibilities. This is known as a catastrophic backtracking. Such a pattern, when applied to strings entered by users, is a security vulnerability: just a few requests are enough to crash a server. The [matchTimeoutMilliseconds] parameter sets a timeout of 100 ms: after that, the search is abandoned with a [RegexMatchTimeoutException] exception (line 6);
  • line 35: the [NonBacktracking] option (.NET 7) uses a different engine, which guarantees a search time proportional to the string’s length. It does not support certain constructs (backward references, such as [\1], for example), but it returns a result immediately in this case.

Note that you must set a timeout or use [NonBacktracking] whenever the pattern or the string being searched comes from outside the program.

5.9. Example Application - V3

We’ll revisit the application covered in Chapter 3 (Version 1) and Chapter 4 (Version 2). Remember that this is an exercise: the tax schedule used is the 2004 version (2003 income), which has remained unchanged since the original course.

5.9.1. Data in a File

In version 2, the tax calculation was performed in the abstract class [AbstractTax], which we reproduce here unchanged, except for its namespace:

namespace TaxV3;

public abstract class AbstractTax : ITax
{
    // the tax brackets needed for the calculation come from an external source
    // that only the derived classes know
    protected abstract TaxBracket[] TaxBrackets { get; }

    // tax calculation
    public int Calculate(bool married, int nbChildren, int salary)
    {
        // calculation of the number of shares
        decimal shares = (decimal)nbChildren / 2 + (married ? 2 : 1);
        if (nbChildren >= 3)
        {
            shares += 0.5m;
        }
        // calculation of the taxable income and of the family quotient
        decimal income = 0.72m * salary;
        decimal quotient = income / shares;
        // search for the tax bracket: the last one is used if no limit fits
        TaxBracket[] brackets = TaxBrackets;
        TaxBracket bracket = brackets[^1];
        for (int i = 0; i < brackets.Length - 1; i++)
        {
            if (quotient <= brackets[i].Limit)
            {
                bracket = brackets[i];
                break;
            }
        }
        // tax calculation
        return (int)(income * bracket.CoeffR - shares * bracket.CoeffN);
    }
}

The [Calculate] method uses the tax brackets returned by the abstract property [TaxBrackets] (line 7). The [AbstractTax] class does not know where these brackets come from: it is up to the derived classes to provide them. In version 2, the derived class [HardwiredTax] hard-coded them into its code. A tax bracket is represented, as in version 2, by the record structure [TaxBracket]:

1
2
3
4
namespace TaxV3;

// a bracket of the tax scale
public readonly record struct TaxBracket(decimal Limit, decimal CoeffR, decimal CoeffN);

and the calculation is described by the interface [ITax]:

1
2
3
4
5
6
7
namespace TaxV3;

public interface ITax
{
    // tax calculation for a taxpayer
    int Calculate(bool married, int nbChildren, int salary);
}

The new version stores the data in a file, which allows you to change the pay scale without recompiling the program. We write two new classes derived from [AbstractTax]:

  • [FileTax] reads the brackets from a text file [TaxData.txt];
  • [JsonTax] reads them from a JSON file named [TaxData.json].

The text file [TaxData.txt] contains one tax bracket per line, in the format limit: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

The 2008 course wrote numbers with a decimal comma (0,068) and read them using [decimal.TryParse] in the machine’s locale: the same file, when read on a British or American machine, would have been rejected or, worse, misinterpreted. A data file must not depend on the language of the machine reading it: we write numbers with a decimal point and read them in the invariant culture ([CultureInfo.InvariantCulture]), which is also used by JSON and most data formats.

The file [TaxData.json] contains the same data, in the form of a JSON array of objects:

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

5.9.2. Errors

Processing these files may fail: missing file, malformed line, incorrect number, etc. As in the 2008 version, we create a special exception class to report these errors. The 2008 course called it [FileTaxException]; we’re naming it [TaxException], since it will also be used for other data sources (the JSON file here, and a database later). It carries an error code:

namespace TaxV3;

// the error codes: powers of 2, which can be combined
[Flags]
public enum ErrorCode
{
    Access = 1,     // the file cannot be read
    Line = 2,       // a line does not have three fields
    Field1 = 4,     // field 1 (limit) is incorrect
    Field2 = 8,     // field 2 (coeffR) is incorrect
    Field3 = 16,    // field 3 (coeffN) is incorrect
    Json = 32       // the JSON document is incorrect
}

// the exception thrown by the classes that read the tax brackets
public class TaxException : Exception
{
    // the error code (a combination of ErrorCode values)
    public int Code { get; init; }

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

Comments:

  • lines 16–22: The class [TaxException] derives from the class [Exception]. It will be used to report any errors that occur while processing the data file. It has two constructors: the first takes an error message, and the second also takes the exception that caused the error, which will be available in the [InnerException] property;
  • Line 19: The property [Code] stores the error code. The keyword [init] allows this to be set only when the object is created, using an object initializer: [new TaxException("...") { Code = 2 }]. It is this form of exception, with an integer code, that subsequent versions of the application will use;
  • lines 4–12: a list of possible error codes:
    • [Access]: error accessing the data file;
    • [Line]: row missing the three expected fields, or file containing no brackets;
    • [Field1], [Field2], [Field3]: Field 1, 2, or 3 in a row is incorrect;
    • [Json]: The document JSON is incorrect.

Some of these errors may occur in combination: all three fields in a row may be incorrect. Therefore, the enumeration [ErrorCode] has been annotated with the attribute [Flags], and its values are powers of 2: each corresponds to a bit of the integer. An error in fields 2 and 3 will result in the code [Field2 | Field3], which is 8 + 16 = 24. Thanks to the [Flags] attribute, the [ToString] method of the enumeration displays such a combination in the form “Field2, Field3.”

5.9.3. The FileTax class

The [FileTax] class provides the brackets read from a text file:

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

namespace TaxV3;

// the tax brackets read from a text file of limit:coeffR:coeffN lines
public partial class FileTax : AbstractTax
{
    // the field separator: the : character surrounded by optional spaces
    [GeneratedRegex(@"\s*:\s*")]
    private static partial Regex Separator();

    // the tax brackets read from the file
    private readonly TaxBracket[] taxBrackets;

    // implementation of the abstract property of the parent class
    protected override TaxBracket[] TaxBrackets => taxBrackets;

    public FileTax(string fileName)
    {
        // the name of the file, without its path, for the error messages
        string name = Path.GetFileName(fileName);
        // the brackets read; we do not know their number in advance
        List<TaxBracket> brackets = [];
        try
        {
            int lineNumber = 0;
            foreach (string lineRead in File.ReadLines(fileName))
            {
                lineNumber++;
                string line = lineRead.Trim();
                // empty lines are ignored
                if (line == "")
                {
                    continue;
                }
                // the line must have three fields
                string[] fields = Separator().Split(line);
                if (fields.Length != 3)
                {
                    throw new TaxException($"Line no. {lineNumber} is incorrect ({line})")
                    {
                        Code = (int)ErrorCode.Line
                    };
                }
                // three positive or zero decimal numbers, written with a decimal point
                ErrorCode code = 0;
                if (!Read(fields[0], out decimal limit)) code |= ErrorCode.Field1;
                if (!Read(fields[1], out decimal coeffR)) code |= ErrorCode.Field2;
                if (!Read(fields[2], out decimal coeffN)) code |= ErrorCode.Field3;
                if (code != 0)
                {
                    throw new TaxException($"Line no. {lineNumber} is incorrect ({line})")
                    {
                        Code = (int)code
                    };
                }
                // we store the new tax bracket
                brackets.Add(new TaxBracket(limit, coeffR, coeffN));
            }
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            // file access error: we wrap it in a TaxException
            throw new TaxException($"Error while reading the file {name}", e)
            {
                Code = (int)ErrorCode.Access
            };
        }
        // at least one bracket is needed
        if (brackets.Count == 0)
        {
            throw new TaxException($"The file {name} does not contain any bracket")
            {
                Code = (int)ErrorCode.Line
            };
        }
        // we transfer the list into the array of brackets
        taxBrackets = brackets.ToArray();
    }

    // conversion of a field into a decimal >= 0, regardless of the culture of the machine
    private static bool Read(string field, out decimal result) =>
        decimal.TryParse(field, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
}

Comments:

  • Line 7: The class [FileTax] is derived from the class [AbstractTax], just as the class [HardwiredTax] was in version 2. It is declared as [partial] because it contains a generated regular expression;
  • lines 10–11: the regular expression for the field separator in a limit:coeffR:coeffN line: the character [:], preceded and followed by any number of spaces;
  • lines 14–17: the brackets read, returned by the property [TaxBrackets], which redefines the abstract property of [AbstractTax];
  • line 19: the constructor receives the name of the text file containing the data. Its role is to populate the array [taxBrackets];
  • line 22: the file name without its path, used in error messages. The constructor actually receives a full path, which does not need to be displayed to the user;
  • line 24: Reading a text file is sequential: the number of lines is not known until the entire file has been read. Therefore, the array cannot be sized in advance; the brackets are temporarily stored in a list;
  • line 28: The file is read line by line using [File.ReadLines], which opens and closes the file itself. The 2008 course used [StreamReader];
  • lines 38–43: The line is split into fields using a regular expression; if there are not three fields, a [TaxException] exception with code [Line] is thrown. The 2008 course stored the error in a variable, exited the loop, and then threw the exception after closing the file: throwing it immediately is simpler, and [File.ReadLines] closes the file in any case, since the loop [foreach] releases its counter even when an exception interrupts it;
  • lines 47–55: conversion of the three fields to decimal numbers. Errors are accumulated in the variable [code] using the operator [|=], then reported together;
  • lines 83–84: conversion of a field. [NumberStyles.AllowDecimalPoint] allows only digits and a decimal point: no signs, spaces, or thousands separators. Negative numbers are therefore rejected, which validates the data at the same time as it is converted. [CultureInfo.InvariantCulture] enforces the period as the decimal separator, regardless of the machine’s locale;
  • lines 62–67: file access errors ([IOException] and its variants, such as [FileNotFoundException], or [UnauthorizedAccessException] for insufficient permissions) are wrapped in a [TaxException] with code [Access]. The [when (...)] exception filter (C# 6) intercepts only these exceptions: [TaxException] exceptions thrown within the loop are not intercepted and exit the constructor unchanged. The pattern [e is IOException or UnauthorizedAccessException] is a combined pattern using [or] (C# 9);
  • lines 71–79: an empty file is rejected; otherwise, the list is transferred to the bracket array, which was the purpose of the constructor.

Encapsulating low-level exceptions ([IOException]) into an application-specific exception ([TaxException]) allows the calling code to handle only one type of exception, regardless of the data source: a text file, a JSON file, and, later in the course, a database.

5.9.4. The JsonTax class

The [JsonTax] class provides the brackets read from a JSON file:

using System.Text.Json;

namespace TaxV3;

// the tax brackets read from a JSON file
public class JsonTax : AbstractTax
{
    // "Web" options: property names in camelCase (limit, coeffR, coeffN)
    private static readonly JsonSerializerOptions options = new(JsonSerializerDefaults.Web);

    // the tax brackets read from the file
    private readonly TaxBracket[] taxBrackets;

    // implementation of the abstract property of the parent class
    protected override TaxBracket[] TaxBrackets => taxBrackets;

    public JsonTax(string fileName)
    {
        // the name of the file, without its path, for the error messages
        string name = Path.GetFileName(fileName);
        string json;
        try
        {
            // reading the file
            json = File.ReadAllText(fileName);
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            throw new TaxException($"Error while reading the file {name}", e)
            {
                Code = (int)ErrorCode.Access
            };
        }
        TaxBracket[]? brackets;
        try
        {
            // deserialization of the array of brackets
            brackets = JsonSerializer.Deserialize<TaxBracket[]>(json, options);
        }
        catch (JsonException e)
        {
            throw new TaxException($"The file {name} is incorrect: {e.Message}", e)
            {
                Code = (int)ErrorCode.Json
            };
        }
        // at least one bracket is needed, without a negative value
        if (brackets is null || brackets.Length == 0)
        {
            throw new TaxException($"The file {name} does not contain any bracket")
            {
                Code = (int)ErrorCode.Json
            };
        }
        if (brackets.Any(t => t.Limit < 0 || t.CoeffR < 0 || t.CoeffN < 0))
        {
            throw new TaxException($"The file {name} contains a negative value")
            {
                Code = (int)ErrorCode.Json
            };
        }
        taxBrackets = brackets;
    }
}

Comments:

  • Line 9: The “Web” options: the JSON properties [limit], [coeffR], and [coeffN] correspond to the properties [Limit], [CoeffR], and [CoeffN] in the [TaxBracket] structure without needing to modify the latter with attributes. The options are stored in a static field: a [JsonSerializerOptions] object stores information about the types it has processed, and it is recommended to reuse it;
  • lines 25–31: reading the file; an access error is handled as in [FileTax];
  • Line 38: A single instruction converts the text JSON into an array of brackets. [System.Text.Json] knows how to construct a record structure by calling its constructor. An invalid document triggers a [JsonException], encapsulated within a [TaxException] of code [Json];
  • lines 48–57: deserialization checks the document’s format, not the validity of the data: it remains to be verified that there is at least one bracket (the document [null] or [] is a valid JSON) and that no values are negative. The method LINQ [Any] performs this final check in a single line.

Comparing the two classes is instructive: with a structured data format and a library that understands it, parsing the file no longer requires any code.

5.9.5. The main program

The [TaxV3] console project has the following directory structure:

05-classes-dotnet/
  05-classes-dotnet.slnx
  TaxV3/
    AbstractTax.cs
    FileTax.cs
    ITax.cs
    JsonTax.cs
    Program.cs
    TaxBracket.cs
    TaxData.json
    TaxData.txt
    TaxDataInvalid.json
    TaxDataInvalid.txt
    TaxException.cs
    TaxV3.csproj

The project file [TaxV3.csproj] is the one created by [dotnet new console], to which a group of elements has been added:

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

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

  <!-- the data files are copied next to the executable -->
  <ItemGroup>
    <None Update="TaxData*.txt;TaxData*.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

</Project>

The element [<None Update="..." CopyToOutputDirectory="PreserveNewest" />] (line 12) specifies that the data files should be copied to the executable’s folder ([bin/Debug/net10.0]) during compilation if they are newer than the existing copy. The character [*] allows you to specify all [TaxData*.txt] and [TaxData*.json] files at once. This is equivalent to the “Copy to Output Directory” property that the 2008 course set in the file properties in Visual Studio.

The [Program.cs] program is as follows:

using TaxV3;

// tax calculation, version 3: the tax brackets are read from a file
// syntax: dotnet run [-- file]; file: TaxData.txt by default, or a .json file

const string syntax = """
    syntax: Married NbChildren Salary
    Married: y for married, n for not married
    NbChildren: number of children
    Salary: annual salary in euros
    """;

// the data file is searched for next to the executable
string fileName = args.Length > 0 ? args[0] : "TaxData.txt";
string path = Path.Combine(AppContext.BaseDirectory, fileName);
Console.WriteLine($"Tax brackets read from [{fileName}]");

// creation of an ITax object; the class depends on the extension of the file
ITax tax;
try
{
    tax = Path.GetExtension(fileName).ToLower() switch
    {
        ".json" => new JsonTax(path),
        _ => new FileTax(path)
    };
}
catch (TaxException e)
{
    // display of the error; the code is displayed as a combination of ErrorCode
    string origin = e.InnerException is null ? "" : $", original exception: {e.InnerException.GetType().Name}";
    Console.WriteLine($"The following error occurred: [Code={(ErrorCode)e.Code}, Message={e.Message}{origin}]");
    // stopping the program
    return 1;
}

// input loop
while (true)
{
    // we ask for the tax calculation parameters
    Console.Write("Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: ");
    string parameters = (Console.ReadLine() ?? "").Trim();
    // anything to do?
    if (parameters == "")
    {
        break;
    }
    // checking the number of fields of the typed line
    string[] fields = parameters.Split(' ', StringSplitOptions.RemoveEmptyEntries);
    if (fields.Length != 3)
    {
        Console.WriteLine(syntax);
        continue;
    }
    // checking the validity of the parameters
    // married
    string married = fields[0].ToLower();
    if (married != "y" && married != "n")
    {
        Console.WriteLine(syntax + "\nIncorrect married argument: type y or n");
        continue;
    }
    // nbChildren
    if (!int.TryParse(fields[1], out int nbChildren) || nbChildren < 0)
    {
        Console.WriteLine(syntax + "\nIncorrect NbChildren argument: type a positive or zero integer");
        continue;
    }
    // salary
    if (!int.TryParse(fields[2], out int salary) || salary < 0)
    {
        Console.WriteLine(syntax + "\nIncorrect salary argument: type a positive or zero integer");
        continue;
    }
    // the parameters are correct: we calculate the tax
    Console.WriteLine($"Tax={tax.Calculate(married == "y", nbChildren, salary)} euros");
}
return 0;

Comments:

  • line 14: the name of the data file is passed as an argument; by default, it is [TaxData.txt];
  • line 15: the file is searched for in the executable’s folder, [AppContext.BaseDirectory], where it was copied during compilation. Thus, the program finds its data regardless of the folder from which it is launched, which was not the case for the single-file applications in this chapter;
  • Lines 22–25: An expression, [switch] (Chapter 3), selects the class to instantiate based on the file extension. The two branches return different types, [JsonTax] and [FileTax]: the compiler infers the type of the expression using the destination variable [tax], which is of type [ITax] (expression [switch] with target type). The rest of the program only knows the interface [ITax] and is unaware of where the data comes from;
  • lines 28–34: Creating the object may trigger a [TaxException], which is displayed before the program terminates. The code is converted to [ErrorCode] to be displayed in plain text; the original exception, if any, is identified by its type (its message contains the full file path);
  • Lines 37–76: The dialog loop is exactly the same as in version 2.

5.9.6. Executions

With the file [TaxData.txt], by default:

> dotnet run
Tax brackets read from [TaxData.txt]
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: q s d
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect married argument: type y or n
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 d
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect salary argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

The values from previous versions are reproduced: 4,282 euros for a married couple with 2 children, 2,959 euros with 3 children, and 11,519 euros for a single person without children, with an annual salary of 60,000 euros. Incorrect entries are flagged as in Version 2.

With the file [TaxData.json]:

1
2
3
4
5
6
7
8
9
> dotnet run -- TaxData.json
Tax brackets read from [TaxData.json]
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

With a non-existent [xx.txt] file:

1
2
3
> dotnet run -- xx.txt
Tax brackets read from [xx.txt]
The following error occurred: [Code=Access, Message=Error while reading the file xx.txt, original exception: FileNotFoundException]

With the following [TaxDataInvalid.txt] file, where the third line uses a decimal comma:

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 -- TaxDataInvalid.txt
Tax brackets read from [TaxDataInvalid.txt]
The following error occurred: [Code=Field2, Field3, Message=Line no. 3 is incorrect (14753:0,191:1322,92)]

Fields 2 and 3 in line 3 are rejected: the error code is the combination “Field2, Field3.” Field 1 (14753), which has no decimal part, is correct.

With the following file, [TaxDataInvalid.json], where a coefficient is written as a string:

1
2
3
4
[
  { "limit": 4962, "coeffR": 0, "coeffN": 0 },
  { "limit": 8382, "coeffR": "0,068", "coeffN": 291.09 }
]
1
2
3
> dotnet run -- TaxDataInvalid.json
Tax brackets read from [TaxDataInvalid.json]
The following error occurred: [Code=Json, Message=The file TaxDataInvalid.json is incorrect: The JSON value could not be converted to System.Decimal. Path: $[1].coeffR | LineNumber: 2 | BytePositionInLine: 36., original exception: JsonException]

The message from [JsonException] specifies the exact location of the error: the [coeffR] property of the second element in the array ([$[1].coeffR]), [LineNumber: 2]—that is, the third line of the document, since [System.Text.Json] numbers the lines starting from 0.

5.10. Conclusion

This chapter has introduced the .NET classes used in almost all programs:

  • immutable strings ([string]) and mutable strings ([StringBuilder]) for building them piece by piece; interpolation with widths and formats, raw strings; views ([ReadOnlySpan<char>]) for critical code;
  • the arrays and the [Array] class;
  • generic collections [List<T>], [Dictionary<TKey,TValue>], [HashSet<T>], [Queue<T>], [Stack<T>], [PriorityQueue<TElement,TPriority>], and their read-only interfaces; the non-generic collections [ArrayList] and [Hashtable] are deprecated;
  • LINQ, its sequences [IEnumerable<T>], its iterators, and its deferred execution;
  • text files ([StreamReader], [StreamWriter], [File]), binary files ([BinaryWriter], [BinaryReader]), and the JSON serialization, which replaces the binary serialization of [BinaryFormatter], removed from .NET;
  • regular expressions, preferably generated at compile time and always protected by a timeout when processing external data.

The sample application now reads its data from a text file or JSON. The following chapter reorganizes it into layers: reading data, calculating taxes, and user interaction will become independent components.