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:
Results:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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”:
Results:
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:
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:
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:
Results:
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]:
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>]:
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:
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:
Results:
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:

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:
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]:
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:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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:
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
usingdeclaration (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:
It is located in the [05-classes-dotnet] folder, from which the program is executed:
With a nonexistent file:
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:
Results:
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:
Results:
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:
The text file [persons1.txt] has the following content:
The program [files-04.cs] is as follows:
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:
The log file [logs.txt] contains the following:
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):
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.
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:
The contents of [persons2.txt] are as follows:
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:
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:
Results:
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:
Results:
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:
Results:
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:
Results:
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.
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:
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:
Results:
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:
Results:
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:
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]:
and the calculation is described by the interface [ITax]:
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:
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:
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:
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:
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:
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:
The project file [TaxV3.csproj] is the one created by [dotnet new console], to which a group of elements has been added:
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:
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:
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]:
With a non-existent [xx.txt] file:
With the following [TaxDataInvalid.txt] file, where the third line uses a decimal comma:
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:
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.