Skip to content

4. Classes, Structures, Interfaces

4.1. Objects by Example

4.1.1. Overview

We will now explore object-oriented programming through examples. An object is an entity that contains data defining its state (referred to as fields, attributes, etc.) and functions (referred to as methods). An object is created based on a template called a class:

public class C1
{
    Type1 p1;          // field p1
    Type2 p2;          // field p2
    ...
    Type3 M3(...)      // method M3
    {
        ...
    }
    Type4 M4(...)      // method M4
    {
        ...
    }
    ...
}

Starting from the previous class [C1], you can create many objects such as [O1], [O2], … All of them will have the fields [p1], [p2], … and the methods [M3], [M4], … However, they will have different values for their [pi] fields, thus each having its own unique state. If [o1] is an object of type [C1], [o1.p1] refers to the field [p1] of [o1], and [o1.M3] refers to the method [M3] of [o1].

Let’s consider an initial object model: the [Person] class.

4.1.2. Creating the C# Project

In the examples from the previous chapter, a program fit into a single source file. From now on, a program may consist of multiple source files: one class per file, plus the main program. These files are grouped together in a project. The 2008 course created projects using the Visual Studio 2008 wizards; here we use the command-line tool [dotnet], which works the same way on Windows, Linux, and macOS. Chapter 2 showed how to open the same project in VS Code or Visual Studio.

In a terminal, navigate to the [04-classes] folder and create a console project named [Persons]:

1
2
3
4
5
6
7
8
> dotnet new console -n Persons
The template "Console App" was created successfully.

Processing post-creation actions...
Restoring .../04-classes/Persons/Persons.csproj:
  Determining projects to restore...
  Restored .../04-classes/Persons/Persons.csproj (in 126 ms).
Restore succeeded.

The command creates a folder named [Persons] containing two files. The file [Persons.csproj] describes the project:

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

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

</Project>
  • Line 4: The project produces an executable;
  • line 5: the target program is .NET 10;
  • line 6: a set of common namespaces ([System], [System.IO], [System.Collections.Generic], [System.Linq], …) is implicitly imported into all source files in the project. This is why we can write *[Console.WriteLine] without the ***[using System;]** clause;
  • Line 7: Nullable reference types are enabled. The compiler issues a warning for any reference-type variable (a string, an object) that might take on the value [null] without the program expecting it. We’ll return to this in the section dedicated to these types; for now, just keep in mind that our programs must compile without any warnings.

The second file, [Program.cs], contains the main program. All [*.cs] files in the project folder (and its subfolders) are part of the project: to add a [Person] class, simply create the file [Person.cs] in the [Persons] folder. To run the project, type [dotnet run] in its folder: the command compiles the project, if it has changed, and then runs it.

All the projects in this chapter are grouped into a solution—a file that references a set of projects—which VS Code or Visual Studio can open all at once. The [.slnx] (XML) format, supported by the [dotnet] command since .NET 9 and the default format since .NET 10, replaces the old [.sln] format:

1
2
3
4
5
> dotnet new sln --format slnx -n 04-classes
The template "Solution File" was created successfully.

> dotnet sln 04-classes.slnx add Persons/Persons.csproj
Project `Persons/Persons.csproj` added to the solution.

The [dotnet build 04-classes.slnx] command then compiles all projects in the solution.

4.1.3. Definition of the Person class

The definition of the [Person] class in the source file [Person.cs] will be as follows:

namespace Persons;

public class Person
{
    // attributes (fields)
    private string firstName = "";
    private string lastName = "";
    private int age;

    // method
    public void Initialize(string p, string n, int age)
    {
        this.firstName = p;
        this.lastName = n;
        this.age = age;
    }

    // method
    public void Identify()
    {
        Console.WriteLine($"[{firstName}, {lastName}, {age}]");
    }
}
  • Line 1: The class belongs to the [Persons] namespace (the project name). This form, [namespace Persons;]—known as “file scope”—places all types in the file within the namespace. Namespaces are discussed at the end of the chapter;
  • Lines 6–8: The class’s three fields. The two strings are initialized with the empty string; otherwise, they would be set to [null] when the object is created, and the compiler—which has nullable types enabled—would issue a warning;
  • lines 11–15: a method that sets the object’s state;
  • lines 19–21: a method that displays the object’s state.

Here we have the definition of a class, and thus a data type. When we create variables of this type, we call them objects or class instances. A class is therefore a template from which objects are constructed.

The members of a class can be data (fields), methods (functions), or properties. Properties are special methods used to retrieve or set the value of an object’s fields. These members can be accompanied by one of the following keywords:

Keyword
Access
[private]
A private member is accessible only by the class’s internal methods. This is the default access level
[public]
A public member is accessible by any method, whether or not it is defined within the class
[protected]
A protected member is accessible only through the internal methods of the class or a derived class (see the concept of inheritance below)
[internal]
An internal member is accessible by any method within the same assembly (the same project)

In general, a class’s data is declared private, while its methods and properties are declared public. This means that the user of an object (the programmer):

  • will not have direct access to the object’s private data;
  • can call the object’s public methods, including those that provide access to its private data.

The syntax for declaring a class [C] is as follows:

1
2
3
4
5
6
public class C
{
    private private data, method or property;
    public public data, method or property;
    protected protected data, method or property;
}

The order in which the members [private], [protected], and [public] are declared is arbitrary.

4.1.4. The Initialize method

What is the purpose of the [Initialize] method? Because [lastName], [firstName], and [age] are private members of the [Person] class, the statements:

1
2
3
4
Person p1;
p1.firstName = "Jean";
p1.lastName = "Dupont";
p1.age = 30;

are invalid. We need to initialize an object of type [Person] using a public method. This is the purpose of the [Initialize] method. We’ll write:

Person p1;
p1.Initialize("Jean", "Dupont", 30);

The notation [p1.Initialize] is valid because [Initialize] is publicly accessible.

4.1.5. The new operator

However, the previous sequence of statements is incorrect. The statement

Person p1;

declares [p1] as a reference to an object of type [Person]. This object does not yet exist, and therefore [p1] is not initialized. When we then write [p1.Initialize("Jean", "Dupont", 30)], we call the method [Initialize] of the object referenced by [p1]. However, this object does not exist, and the compiler reports the error: the local variable [p1] has not been assigned a value. If we write:

Person? p1 = null;
p1.Initialize("Jean", "Dupont", 30);

we explicitly indicate with the keyword [null] that the variable [p1] does not yet reference any object (the question mark in [Person?] indicates that the variable may be set to [null]). The compiler then issues only a warning (dereferencing a null reference), and at runtime, the method call throws a [NullReferenceException] exception. For [p1] to reference an object, you must write:

Person p1 = new Person();

This creates an object of type [Person] that has not yet been initialized: its fields have their default values, in this case the empty string for [lastName] and [firstName], and 0 for [age] (a numeric field without an explicit initial value is 0; a reference-type field is [null]). Now that [p1] references an object, the initialization statement for that object, [p1.Initialize("Jean", "Dupont", 30)], is valid.

4.1.6. The this keyword

Let’s look at the code for the [Initialize] method:

    public void Initialize(string p, string n, int age)
    {
        this.firstName = p;
        this.lastName = n;
        this.age = age;
    }

The statement [this.firstName = p] means that the field [firstName] of the current object ([this]) is assigned the value [p]. The keyword [this] refers to the current object—the one on which the method is being executed. How do we know this? Let’s look at how the object referenced by [p1] is initialized in the calling program:

p1.Initialize("Jean", "Dupont", 30);

It is the method [Initialize] of the object [p1] that is called. When, within this method, the object [this] is referenced, it actually refers to the object [p1]. The method [Initialize] could also have been written as follows:

1
2
3
4
5
6
    public void Initialize(string p, string n, int age)
    {
        firstName = p;
        lastName = n;
        this.age = age;
    }

When a method of an object references a field [A] of that object, the notation [this.A] is implied. It must be used explicitly when there is a conflict of identifiers. This is the case with the statement [this.age = age;], where [age] refers to both a field of the current object and the parameter [age] received by the method. In this case, the ambiguity must be resolved by referring to the field [age] as [this.age].

4.1.7. A Test Program

Here is a short test program. It is written in the source file [Program.cs] of the project:

1
2
3
4
5
6
using Persons;

// a Person object
Person p1 = new Person();
p1.Initialize("Jean", "Dupont", 30);
p1.Identify();
  • Line 1: The main program is written using high-level statements that do not belong to any namespace. To refer to the class [Person] by its name alone, you must import its namespace [Persons] using the clause [using];
  • Line 4: Creation of a [Person] object referenced by [p1];
  • Line 5: Initialize the object;
  • Line 6: Display its status.

Results:

> dotnet run
[Jean, Dupont, 30]

4.1.8. Single-file applications

For a short example, a project is unnecessarily cumbersome. Since version 10, a standalone source file can be executed directly by .NET, without a [.csproj] file: this is a single-file application, which we used in the previous chapter. The project options (nullable types enabled, implicit [using]) are the same as those for a console project. We’ll use this format whenever the example contains only one or two classes.

The file then contains the top-level statements followed by the class declarations: the compiler enforces this order, as the top-level statements must precede any type declarations (class, structure, interface, etc.) in the file. Classes declared in this way do not belong to any namespace, which is irrelevant in a single-file program.

4.1.9. Another Method: Overloading

Let’s continue with the class *[Person] and add a second method, *[Initialize], to it. The program *[classes-01.cs]* is as follows:

// overloading of the Initialize method
Person p1 = new Person();
p1.Initialize("Jean", "Dupont", 30);
p1.Identify();
Person p2 = new Person();
p2.Initialize(p1);
p2.Identify();

// the Person class is declared after the top-level statements
public class Person
{
    // attributes
    private string firstName = "";
    private string lastName = "";
    private int age;

    // initialization from three values
    public void Initialize(string p, string n, int age)
    {
        firstName = p;
        lastName = n;
        this.age = age;
    }

    // initialization from another person
    public void Initialize(Person p)
    {
        firstName = p.firstName;
        lastName = p.lastName;
        age = p.age;
    }

    // identity of the person
    public void Identify()
    {
        Console.WriteLine($"[{firstName}, {lastName}, {age}]");
    }
}
  • lines 2–7: the high-level instructions, i.e., the main program;
  • Line 10: The class [Person] is declared after the high-level statements;
  • lines 18–22: the method [Initialize] from the previous paragraph;
  • lines 26–30: a second method, [Initialize].

We now have two methods named [Initialize]: this is valid as long as they accept different parameters (in number or type). The method [Initialize] is said to be overloaded. The compiler chooses which method to call based on the actual parameters: [p1.Initialize("Jean", "Dupont", 30)] (line 3) calls the first one, and [p2.Initialize(p1)] (line 6) calls the second one.

The parameter of the second method is a reference ([p]) to a person. The fields of the person ([p]) are then assigned to the current object ([this]). Note that the method [Initialize] has direct access to the fields of the object [p] even though they are private (lines 28–30). This is always true: a method of a class [C] has access to the private members of all objects of the same class [C].

Results:

1
2
3
> dotnet run classes-01.cs
[Jean, Dupont, 30]
[Jean, Dupont, 30]

4.1.10. Constructors of the Person class

A constructor is a method that bears the name of the class and is called when the object is created. It is generally used to initialize the object. It is a method that can accept parameters but does not return a value. Its declaration is not preceded by any type (not even [void]).

If a class [C] has a constructor that accepts n parameters, the declaration and initialization of an object of this class can be done as follows:

C obj = new C(arg1, arg2, ... argn);

or

1
2
3
C obj;
...
obj = new C(arg1, arg2, ... argn);

When a class [C] has one or more constructors, one of these constructors must be used to create an object of that class. If a class [C] has no constructors, it has a default constructor, which is the parameterless constructor [public C()]. The object’s fields then retain their initial values. This is what happened in the previous programs, where we had written [new Person()].

The program [classes-02.cs] provides three constructors for the class [Person]:

// constructors of the Person class
Person p1 = new Person("Jean", "Dupont", 30);
p1.Identify();
Person p2 = new Person(p1);
p2.Identify();
Person p3 = new Person();
p3.Identify();

public class Person
{
    // attributes
    private string firstName;
    private string lastName;
    private int age;

    // main constructor
    public Person(string firstName, string lastName, int age)
    {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    // copy constructor: it calls the main constructor
    public Person(Person p) : this(p.firstName, p.lastName, p.age)
    {
    }

    // parameterless constructor: it too calls the main constructor
    public Person() : this("?", "?", 0)
    {
    }

    // identity of the person
    public void Identify()
    {
        Console.WriteLine($"[{firstName}, {lastName}, {age}]");
    }
}
  • lines 17–21: the “main” constructor initializes the three fields. Since its parameters have the same names as the fields, the fields are distinguished using [this];
  • line 25: the copy constructor. The notation [: this(p.firstName, p.lastName, p.age)] means that before executing the (empty) body of the constructor, the constructor of the same class that accepts three parameters—[string], [string], [int]—is executed. This is referred to as chained constructors. The initialization code is thus written only once;
  • line 30: the parameterless constructor, which is also chained to the main constructor. Since the class has declared constructors, it no longer has a default constructor: if we want to be able to write [new Person()], we must declare it;
  • Lines 11–14: The two strings are no longer initialized upon declaration. This is no longer necessary: all constructors, either directly or through chaining, assign a value to them, and the compiler verifies this.

The 2008 course had constructors call the [Initialize] method. Chaining with [this(...)] is preferable: with nullable types enabled, the compiler does not inspect the contents of methods called by a constructor and would flag that the fields [firstName] and [lastName] could be set to [null] at the end of construction.

Results:

1
2
3
4
> dotnet run classes-02.cs
[Jean, Dupont, 30]
[Jean, Dupont, 30]
[?, ?, 0]

4.1.11. Object references

We use the [Person] class from the previous program, omitting its parameterless constructor but including the [Initialize(string, string, int)] method from the beginning of the chapter. The test program becomes the following [classes-03.cs] (we do not reproduce the class):

// object references
// p1
Person p1 = new Person("Jean", "Dupont", 30);
Console.Write("p1="); p1.Identify();
// p2 references the same object as p1
Person p2 = p1;
Console.Write("p2="); p2.Identify();
// p3 references an object that is a copy of the object referenced by p1
Person p3 = new Person(p1);
Console.Write("p3="); p3.Identify();
// we change the state of the object referenced by p1
p1.Initialize("Micheline", "Benoît", 67);
Console.Write("p1="); p1.Identify();
// since p2 = p1, the object referenced by p2 has changed state
Console.Write("p2="); p2.Identify();
// p3 does not reference the same object as p1: the object referenced by p3 has not changed
Console.Write("p3="); p3.Identify();
// are p1 and p2 the same reference? and p1 and p3?
Console.WriteLine($"p1 == p2 : {p1 == p2}, p1 == p3 : {p1 == p3}");

Results:

1
2
3
4
5
6
7
8
> dotnet run classes-03.cs
p1=[Jean, Dupont, 30]
p2=[Jean, Dupont, 30]
p3=[Jean, Dupont, 30]
p1=[Micheline, Benoît, 67]
p2=[Micheline, Benoît, 67]
p3=[Jean, Dupont, 30]
p1 == p2 : True, p1 == p3 : False

When the variable [p1] is declared as [Person p1 = new Person("Jean", "Dupont", 30);] (line 3), [p1] refers to the object [Person("Jean", "Dupont", 30)] but is not the object itself. In C, this would be referred to as a pointer—that is, the address of the created object. If we then write [p1 = null;], it is not the object [Person("Jean", "Dupont", 30)] that is modified; rather, it is the reference [p1] that changes its value. The object will be “lost” if it is not referenced by any other variable: the memory it occupies will later be reclaimed by the garbage collector for .NET.

When we write [Person p2 = p1;] (line 6), we initialize the reference [p2]: it “points” to the same object (it refers to the same object) as the reference [p1]. Thus, if the object “pointed to” (or referenced) by [p1] is modified, the object referenced by [p2] is also modified (lines 5–6 of the results).

When [Person p3 = new Person(p1);] is written (line 9), a new object, [Person], is created. This new object will be referenced by [p3]. If the object referenced by [p1] is modified, the object referenced by [p3] (line 7) remains unchanged.

Finally, on line 19, the operator [==], applied to two object references, indicates whether they refer to the same object: [p1] and [p2] refer to the same object, while [p1] and [p3] refer to two different objects, although they have the same content (line 8).

4.1.12. Passing Object Reference Parameters

In the previous chapter, we examined how function parameters are passed when they are simple types ([int], [double], …). Let’s see what happens when the parameter is an object reference. For this, we’ll use the class [StringBuilder] from .NET. A [string] object cannot be modified: an operation such as [s.ToUpper()] returns a new [string] object. An object [StringBuilder], on the other hand, is a mutable string: its method [Append] adds characters to the string without creating a new object.

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

using System.Text;

// passing parameters of object reference type
StringBuilder sb0 = new StringBuilder("test0"), sb1 = new StringBuilder("test1"), sb2 = new StringBuilder("test2");
Console.WriteLine($"In calling function before call: sb0={sb0}, sb1={sb1}, sb2={sb2}");
ChangeStringBuilder(sb0, sb1, ref sb2, out StringBuilder sb3);
Console.WriteLine($"In calling function after call: sb0={sb0}, sb1={sb1}, sb2={sb2}, sb3={sb3}");

// the called function
static void ChangeStringBuilder(StringBuilder sbf0, StringBuilder sbf1, ref StringBuilder sbf2, out StringBuilder sbf3)
{
    Console.WriteLine($"Start of called function: sbf0={sbf0}, sbf1={sbf1}, sbf2={sbf2}");
    sbf0.Append("*****");
    sbf1 = new StringBuilder("test1*****");
    sbf2 = new StringBuilder("test2*****");
    sbf3 = new StringBuilder("test3*****");
    Console.WriteLine($"End of called function: sbf0={sbf0}, sbf1={sbf1}, sbf2={sbf2}, sbf3={sbf3}");
}
  • line 1: the class [StringBuilder] belongs to the namespace [System.Text], which is not part of the implicitly imported namespaces;
  • line 4: three [sb0, sb1, sb2] references to objects of type [StringBuilder];
  • line 6: These are passed to the function [ChangeStringBuilder] with different modes: [sb0] and [sb1] with the default mode, [sb2] with the keyword [ref], and a fourth reference, [sb3], declared within the call itself ([out StringBuilder sb3]), with the keyword [out];
  • lines 10–17: a local function with the formal parameters [sbf0, sbf1, sbf2, sbf3]. The relationships between the formal parameters [sbfi] and the actual parameters [sbi] are as follows:
    • [sbf0] and [sb0] are, when the function starts, two distinct references that point to the same object (references are passed by value);
    • the same applies to [sbf1] and [sb1];
    • [sbf2] and [sb2] are one and the same reference (keyword [ref]);
    • [sbf3] and [sb3] are also the same reference (keyword [out]), which the function must initialize.

Results:

1
2
3
4
5
> dotnet run classes-04.cs
In calling function before call: sb0=test0, sb1=test1, sb2=test2
Start of called function: sbf0=test0, sbf1=test1, sbf2=test2
End of called function: sbf0=test0*****, sbf1=test1*****, sbf2=test2*****, sbf3=test3*****
In calling function after call: sb0=test0*****, sb1=test1, sb2=test2*****, sb3=test3*****

Explanations:

  • [sb0] and [sbf0] are two distinct references to the same object. This object was modified via [sbf0] (line 13). This modification is visible via [sb0] (line 5);
  • [sb1] and [sbf1] are two distinct references to the same object. [sbf1] is assigned a new value within the function and now points to a new object (line 14). This does not change the value of [sb1], which continues to point to the original, unmodified object;
  • [sb2] and [sbf2] are the same reference. [sbf2] now points to a new object (line 15). Since [sbf2] and [sb2] are one and the same entity, [sb2] also points to this new object;
  • before the function call, [sb3] had no value. After the call, [sb3] refers to the object created by the function (line 16).

4.1.13. Temporary Objects

In an expression, you can explicitly call an object’s constructor: the object is created, but there is no variable to access it later (to modify it, for example). This temporary object is created for the purpose of evaluating the expression and is then discarded. The memory space it occupied will be automatically reclaimed later by the garbage collector, whose role is to reclaim memory space occupied by objects that are no longer referenced by the program’s data.

Consider the following program, [classes-05.cs], where the constructors of the [Person] class display a message:

// temporary objects
new Person(new Person("Jean", "Dupont", 30)).Identify();

public class Person
{
    // attributes
    private string firstName;
    private string lastName;
    private int age;

    // constructors
    public Person(string firstName, string lastName, int age)
    {
        Console.WriteLine("Constructor Person(string, string, int)");
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public Person(Person p)
    {
        Console.WriteLine("Constructor Person(Person)");
        firstName = p.firstName;
        lastName = p.lastName;
        age = p.age;
    }

    // identity of the person
    public void Identify()
    {
        Console.WriteLine($"[{firstName}, {lastName}, {age}]");
    }
}
  • Line 2: The object [Person("Jean", "Dupont", 30)] is constructed and then passed to the copy constructor, which constructs a second object; the method [Identify] is then called on that object. Neither of the two objects is referenced by a variable.

Results:

1
2
3
4
> dotnet run classes-05.cs
Constructor Person(string, string, int)
Constructor Person(Person)
[Jean, Dupont, 30]

The results show the successive creation of the two temporary objects.

4.1.14. Methods for Reading and Writing Private Fields

We add the necessary methods to the [Person] class to read or modify the state of the objects’ fields ([classes-06.cs]):

// read methods (getters) and write methods (setters) of private attributes
Person p = new Person("Jean", "Michelin", 34);
Console.WriteLine("p=(" + p.GetFirstName() + "," + p.GetLastName() + "," + p.GetAge() + ")");
p.SetAge(56);
Console.WriteLine("p=(" + p.GetFirstName() + "," + p.GetLastName() + "," + p.GetAge() + ")");

public class Person
{
    // attributes
    private string firstName;
    private string lastName;
    private int age;

    // constructor
    public Person(string firstName, string lastName, int age)
    {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    // getters
    public string GetFirstName()
    {
        return firstName;
    }

    public string GetLastName()
    {
        return lastName;
    }

    public int GetAge()
    {
        return age;
    }

    // setters
    public void SetFirstName(string firstName)
    {
        this.firstName = firstName;
    }

    public void SetLastName(string lastName)
    {
        this.lastName = lastName;
    }

    public void SetAge(int age)
    {
        this.age = age;
    }
}
  • lines 22–35: the read methods, called getters;
  • lines 38–51: the write methods, called setters.

Results:

1
2
3
> dotnet run classes-06.cs
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)

This approach is typical of the Java language. C# offers a more elegant mechanism: properties.

4.1.15. Properties

There is another way to access a class’s fields: by creating properties. These allow us to manipulate private fields as if they were public.

A property allows you to read ([get]) or set ([set]) the value of a field. A property is declared as follows:

1
2
3
4
5
public Type Property
{
    get { ... }
    set { ... }
}

where [Type] is generally the type of the field managed by the property. It may have two methods named [get] and [set]. The method [get] is usually responsible for returning the value of the field it manages (it could return something else; there’s nothing preventing it from doing so). The [set] method receives a parameter named [value], which it normally assigns to the field it manages. It can use this opportunity to verify the validity of the received value and, if necessary, throw an exception if the value is invalid.

Consider the program [classes-07.cs], where the previous accessors and mutators have been replaced by read-write properties:

// properties
Person p = new Person("Jean", "Michelin", 34);
Console.WriteLine("p=(" + p.FirstName + "," + p.LastName + "," + p.Age + ")");
p.Age = 56;
Console.WriteLine("p=(" + p.FirstName + "," + p.LastName + "," + p.Age + ")");
try
{
    p.Age = -4;
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}
// object initializer: parameterless constructor then assignment of the properties
Person p2 = new Person { Age = 7, FirstName = "Arthur", LastName = "Martin" };
Console.WriteLine($"p2=({p2.FirstName},{p2.LastName},{p2.Age})");
try
{
    Person p3 = new Person { FirstName = "Paul", LastName = " " };
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}

public class Person
{
    // attributes
    private string firstName = "";
    private string lastName = "";
    private int age;

    // constructors
    public Person(string firstName, string lastName, int age)
    {
        // we go through the properties to benefit from their checks
        FirstName = firstName;
        LastName = lastName;
        Age = age;
    }

    public Person()
    {
    }

    // properties
    public string FirstName
    {
        get { return firstName; }
        set
        {
            // valid first name?
            if (string.IsNullOrWhiteSpace(value))
            {
                throw new ArgumentException($"invalid first name ({value})");
            }
            firstName = value;
        }
    }

    public string LastName
    {
        get { return lastName; }
        set
        {
            // valid last name?
            if (string.IsNullOrWhiteSpace(value))
            {
                throw new ArgumentException($"invalid last name ({value})");
            }
            lastName = value;
        }
    }

    public int Age
    {
        get { return age; }
        set
        {
            // valid age?
            if (value < 0)
            {
                throw new ArgumentException($"invalid age ({value})");
            }
            age = value;
        }
    }
}

The [set] methods for the three properties validate the received value (lines 52–57, 66–71, 80–85).

  • Lines 53–55: The method [string.IsNullOrWhiteSpace] returns [true] if the string is [null], empty, or consists solely of spaces. The exception thrown is of type [ArgumentException], the exception defined by .NET to signal an invalid parameter. The 2008 version threw an exception of the general type [Exception], which requires the caller to catch all exceptions;
  • lines 34–39: the constructor initializes the object via properties rather than fields. This allows it to take advantage of the validations performed by the [set] methods, which are implemented in only one place.

How are the methods [get] and [set] called? In the statement on line 3, the program attempts to retrieve the values of the properties [FirstName], [LastName], and [Age] for the person [p]. The [get] method for these properties is then called, and it returns the value of the field they manage. In the [p.Age = 56;] statement (line 4), we want to set the value of the [Age] property. The [set] method of this property is then called. It receives 56 as its [value] parameter.

A property [P] of a class [C] that defines only the method [get] is said to be read-only. If [c] is an object of class [C], the operation [c.P = value] will be rejected by the compiler.

Results:

1
2
3
4
5
6
> dotnet run classes-07.cs
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)
invalid age (-4)
p2=(Arthur,Martin,7)
invalid last name ( )

Line 4 of the results shows that the assignment [p.Age = -4] (line 8) was rejected by the method [set] of the property [Age].

4.1.16. Object initializers

Properties can be used in conjunction with a constructor using the following syntax:

Class obj = new Class(...) { Property1 = val1, Property2 = val2, ... };

This syntax, known as an object initializer, is equivalent to the following code:

1
2
3
4
Class obj = new Class(...);
obj.Property1 = val1;
obj.Property2 = val2;
...

The order of the properties does not matter. In the previous program [classes-07.cs], the class [Person] has a parameterless constructor that does not initialize anything. Let’s return to the end of the main program:

// object initializer: parameterless constructor then assignment of the properties
Person p2 = new Person { Age = 7, FirstName = "Arthur", LastName = "Martin" };
Console.WriteLine($"p2=({p2.FirstName},{p2.LastName},{p2.Age})");
try
{
    Person p3 = new Person { FirstName = "Paul", LastName = " " };
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}

Line 15: The parameterless constructor is used to create a person, which is then initialized by its properties. We could have written [new Person() { Age = 7, ... }], but parentheses are not required for the parameterless constructor in this syntax. The [set] methods are indeed called: on line 19, the name containing a space is rejected (line 6 of the results).

4.1.17. Automatic properties

In many cases, the methods [get] and [set] of a property simply read from and write to a private field without any further processing. In such cases, you can use an automatic property declared as follows:

public Type Property { get; set; }

The private field associated with the property is not declared; it is automatically generated by the compiler and can only be accessed through its property. Thus, instead of writing the field [firstName] and the property [FirstName] for the program [classes-07.cs], you can write:

    public string FirstName { get; set; } = "";

without declaring the private field [firstName]. The expression [= ""] following the property is the initial value of the hidden field. The difference between the two properties is that the first one validates the first name in [set], while the second one performs no validation.

Is using the automatic property [FirstName] equivalent to declaring a public field [public string FirstName;]? For the user of the class, the syntax is the same. However, declaring a class field as public is not recommended: it violates the concept of encapsulation of an object’s state, which should be private and exposed through public methods. With an automatic property, checks can be added later in [set] without changing the code of the class’s users.

Automatic properties have several variants, as demonstrated by the program [classes-08.cs]:

// automatic, read-only, init and required properties
var p = new Person("Dupont", 30) { FirstName = "Jean", City = "Angers" };
Console.WriteLine(p.Identity);
// FirstName is read / write
p.FirstName = "Jacques";
// Age can only be modified inside the class
p.GrowOlder();
Console.WriteLine(p.Identity);
// the following lines are rejected by the compiler
// p.LastName = "Durand"; // LastName is read-only
// p.Age = 40;           // the set of Age is private
// p.City = "Paris";     // City can only be set when the object is created

// mandatory property (required)
var c = new Contact { Email = "jean.dupont@example.com" };
Console.WriteLine($"Contact: {c.Email}, {c.Phone}");
// var c2 = new Contact();   // rejected: the mandatory property Email is not initialized

public class Person
{
    // constructor
    public Person(string lastName, int age)
    {
        LastName = lastName;
        Age = age;
    }

    // read / write automatic property, with an initial value
    public string FirstName { get; set; } = "";
    // read-only automatic property: set by the constructor
    public string LastName { get; }
    // automatic property whose writing is reserved to the class
    public int Age { get; private set; }
    // init property: set by the constructor or by an object initializer
    public string City { get; init; } = "?";
    // read-only computed property (expression-bodied member)
    public string Identity => $"[{FirstName}, {LastName}, {Age}, {City}]";

    // expression-bodied method
    public void GrowOlder() => Age++;
}

public class Contact
{
    // the property must be initialized when the object is created
    public required string Email { get; init; }
    // optional property
    public string Phone { get; init; } = "unknown";
}
  • line 29: a read-write automatic property with an initial value;
  • line 31: a read-only automatic property—it has no [set] and can only be set by a constructor (line 24);
  • line 33: the [set] method may have more restricted access than the property: here, the [Age] property is readable by everyone but can only be modified by the class’s methods, such as [GrowOlder] (line 40);
  • line 35: a [init] property (C# 9): it can be set by a constructor or an object initializer (line 2), but not afterward. This is a way to create immutable objects while taking advantage of the object initializer syntax;
  • line 37: a read-only property whose value is computed. The notation [=> expression] is a shorthand form of [get { return expression; }]. It also applies to methods (line 40): these are referred to as expression-bodied members;
  • line 46: a required property (C# 11): the keyword [required] requires the code that creates a [Contact] object to initialize the [Email] property in an object initializer (line 15). The compiler rejects line 17 if it is uncommented;
  • lines 10–12: three statements that the compiler rejects, for the reasons given in the comments.

Recall that with the keyword [var] (line 2), the variable’s type is inferred from the expression that initializes it, in this case [Person].

Results:

1
2
3
4
> dotnet run classes-08.cs
[Jean, Dupont, 30, Angers]
[Jacques, Dupont, 31, Angers]
Contact: jean.dupont@example.com, unknown

Finally, an automatic property can be declared virtual ([public virtual string Prop { get; set; }]) and overridden in a derived class, for example, to add a validation in [set]. The keywords [virtual] and [override] are introduced with inheritance.

4.1.18. The field keyword (C# 14)

Between the automatic property, which does not perform any validation, and the full property in the [classes-07.cs] program, which requires a field to be declared, C# 14 offers an intermediate option: in a property’s accessors, the keyword [field] refers to the field created by the compiler. This allows you to write a validation without declaring a field:

// the field keyword (C# 14)
var p = new Person { FirstName = "  jean ", LastName = "Dupont", Age = 30 };
Console.WriteLine(p.Identity);
try
{
    p.LastName = "";
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}
try
{
    p.Age = -4;
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}
Console.WriteLine(p.Identity);

public class Person
{
    // properties with checks, without a declared field:
    // [field] denotes the field created by the compiler
    public string FirstName
    {
        get;
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException($"invalid first name ({value})")
            : value.Trim();
    } = "";   // initial value of the hidden field

    public string LastName
    {
        get;
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException($"invalid last name ({value})")
            : value.Trim();
    } = "";

    public int Age
    {
        get;
        set
        {
            if (value < 0)
            {
                throw new ArgumentException($"invalid age ({value})");
            }
            field = value;
        }
    }

    // identity
    public string Identity => $"[{FirstName}, {LastName}, {Age}]";
}
  • Lines 26–31: The property [FirstName]. Its method [get] is automatic ([get;]): it returns the value of the hidden field. Its method [set] checks the received value and assigns it—with leading and trailing spaces removed—to the hidden field designated by [field]. The expression [condition ? throw ... : value] uses a throw statement: if the condition is true, an exception is thrown; otherwise, the expression evaluates to [value.Trim()];
  • line 32: the initial value of the hidden field;
  • lines 42–51: a [set] written using a block of statements.

Results:

1
2
3
4
5
> dotnet run classes-09.cs
[jean, Dupont, 30]
invalid last name ()
invalid age (-4)
[jean, Dupont, 30]

The first name “jean,” provided surrounded by spaces (line 2 of the program), was stored without them (line 2 of the results).

4.1.19. Class Methods and Attributes

Suppose we want to count the number of [Person] objects created in an application. We could manage a counter ourselves, but we risk overlooking temporary objects that are created here and there. It seems safer to include a statement in the constructors of the [Person] class that increments a counter. This counter cannot be a regular field, since each object has its own fields: it is a field of the class itself, not of a particular instance of that class. We declare it using the keyword [static]:

    private static long nbPersons;

To refer to it, we write [Person.nbPersons] to indicate that it is a field of the [Person] class itself. Here, we have created a private field that cannot be accessed directly from outside the class. We therefore create a public property to provide access to the class field [nbPersons]. To return the value of [nbPersons], the [get] method of this property does not require a specific [Person] object: so we also need a property declared as [static], which will be called from the outside using the syntax [Person.NbPersons]. Here is an example of [classes-10.cs]:

// class members (static)
Person p1 = new Person("Jean", "Dupont", 30);
Person p2 = new Person(p1);
new Person(p1);
Console.WriteLine($"Number of persons created: {Person.NbPersons}");
// a class method
Person p3 = Person.Unknown();
p3.Identify();
Console.WriteLine($"Number of persons created: {Person.NbPersons}");

public class Person
{
    // class attribute
    private static long nbPersons;

    // class property
    public static long NbPersons
    {
        get { return nbPersons; }
    }

    // class method
    public static Person Unknown()
    {
        return new Person("?", "?", 0);
    }

    // instance attributes
    private string firstName;
    private string lastName;
    private int age;

    // constructors
    public Person(string firstName, string lastName, int age)
    {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
        nbPersons++;
    }

    public Person(Person p) : this(p.firstName, p.lastName, p.age)
    {
    }

    // identity of the person
    public void Identify()
    {
        Console.WriteLine($"[{firstName}, {lastName}, {age}]");
    }
}
  • line 14: the class field. Like any numeric field, it is initialized to 0;
  • lines 17–19: the read-only class property. It could have been written more simply as [public static long NbPersons { get; private set; }];
  • lines 23–25: a class method. It is called using the class name: [Person.Unknown()] (line 7). A class method has no current object: the keyword [this] has no meaning here, and it can only directly access the class members [static];
  • Line 39: The primary constructor increments the counter. The copy constructor, which is chained to the primary constructor, therefore increments it as well.

Results:

1
2
3
4
> dotnet run classes-10.cs
Number of persons created: 3
[?, ?, 0]
Number of persons created: 4

The program correctly counted the temporary object created on line 4. We have been using class members from the start: [Console.WriteLine], [int.Parse], and [Math.Sqrt] are methods of [static]. [Console] and [Math] are even static classes ([static class]): they contain only members of type [static], and no instances of them can be created.

4.1.20. An array of people

An object is a piece of data like any other, and as such, multiple objects can be grouped together in an array [classes-11.cs]:

// an array of persons
Person[] friends = new Person[3];
friends[0] = new Person("Jean", "Dupont", 30);
friends[1] = new Person("Sylvie", "Vartan", 52);
friends[2] = new Person("Neil", "Armstrong", 66);
// display
foreach (Person friend in friends)
{
    friend.Identify();
}
  • Line 2: creates an array of 3 elements of type [Person]. These 3 elements are initialized here with the value [null], meaning they do not reference any objects. Technically speaking, we refer to this as an “array of objects,” even though it is merely an array of object references. The creation of the array—which is itself an object (as indicated by [new])—does not create any objects of the same type as its elements; this must be done subsequently. Note that the compiler does not flag the fact that the elements of an array of type [Person[]] are equal to [null] immediately after its creation: this is a limitation of nullable type analysis;
  • Lines 3–5: Create 3 objects of type [Person];
  • lines 7–9: display the contents of the table [friends].

The class [Person], declared below, is derived from the program [classes-03.cs], reduced to its three-parameter constructor and its method [Identify]. Results:

1
2
3
4
> dotnet run classes-11.cs
[Jean, Dupont, 30]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]

4.1.21. new() expressions with a target type

When the type of the object to be created is known from the context, C# 9 allows the class name to be omitted after [new]. The program [classes-12.cs] demonstrates this:

// object initializers and target-typed new() expressions
// the type of the created object is inferred from the type of the variable
Person p1 = new("Jean", "Dupont", 30);
Person p2 = new() { FirstName = "Arthur", LastName = "Martin", Age = 7 };
// an array initialized by a collection expression
Person[] friends =
[
    p1,
    p2,
    new("Sylvie", "Vartan", 52),
    new() { FirstName = "Neil", LastName = "Armstrong", Age = 66 },
];
foreach (Person friend in friends)
{
    Console.WriteLine(friend.Identity);
}
// target-typed new() in a method call: the type is that of the parameter
Display(new("Pauline", "Leduc", 12));

// a local function
static void Display(Person p)
{
    Console.WriteLine($"Display: {p.Identity}");
}

public class Person
{
    // constructors
    public Person(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
    }

    public Person()
    {
    }

    // automatic properties
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
    // identity
    public string Identity => $"[{FirstName}, {LastName}, {Age}]";
}
  • Line 3: The type of the object created by [new(...)] is that of the variable that receives it, [Person]. This syntax is the opposite of [var]: with [var], the type of the variable is inferred from the expression; with [new()], the type of the object is inferred from the variable;
  • line 4: [new()] with an object initializer;
  • lines 6–11: an array created and populated by a collection expression (C# 12): the elements are enclosed in square brackets and separated by commas. The type of the elements, [Person], is known: [new(...)] is sufficient to create them;
  • line 18: the type of the created object is that of the parameter of the [Display] function.

Results:

1
2
3
4
5
6
> dotnet run classes-12.cs
[Jean, Dupont, 30]
[Arthur, Martin, 7]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]
Display: [Pauline, Leduc, 12]

4.2. Inheritance by Example

4.2.1. Overview

Here we discuss the concept of inheritance. The purpose of inheritance is to “customize” an existing class so that it meets our needs. Suppose we want to create a class named [Teacher]: a teacher is a specific type of person. They have attributes that other people do not have: the subject they teach, for example. But they also have the attributes common to all people: first name, last name, and age. A teacher is therefore a full member of the class [Person] but has additional attributes. Rather than creating a class [Teacher] from scratch, we prefer to build upon the existing class [Person] and adapt it to the specific characteristics of teachers. The concept of inheritance allows us to do this.

To indicate that the class [Teacher] inherits the properties of the class [Person], we write:

public class Teacher : Person

[Person] is called the parent (or base) class, and [Teacher] is called the derived (or child) class. A [Teacher] object has all the characteristics of a [Person] object: it has the same fields and methods. These fields and methods from the parent class are not repeated in the definition of the child class; we simply specify the fields and methods added by the child class. A C# class can be derived from only one parent class.

The examples in this section are two console projects, [Inheritance1] and [Inheritance2], from the [04-classes.slnx] solution. In the [Inheritance1] project, the [Person] class is defined as follows: [Person.cs]:

namespace Inheritance1;

public class Person
{
    // class property: the number of persons created
    public static long NbPersons { get; private set; }

    // constructor
    public Person(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
        NbPersons++;
        Console.WriteLine("Constructor Person(string, string, int)");
    }

    // properties
    public string FirstName
    {
        get;
        set => field = Check(value, "first name");
    } = "";

    public string LastName
    {
        get;
        set => field = Check(value, "last name");
    } = "";

    public int Age
    {
        get;
        set => field = value >= 0 ? value : throw new ArgumentException($"invalid age ({value})");
    }

    // identity of the person
    public string Identity => $"[{FirstName}, {LastName}, {Age}]";

    // check of a mandatory string
    private static string Check(string text, string label)
    {
        if (string.IsNullOrWhiteSpace(text))
        {
            throw new ArgumentException($"invalid {label} ({text})");
        }
        return text;
    }
}
  • line 6: the person counter from the previous paragraph, this time written as an automatic class property whose definition is reserved for the class;
  • lines 9–15: the constructor initializes the object using its properties and displays a progress message;
  • lines 19–34: three properties that validate their values using the keyword [field]. The validations for the first name and last name, which are identical, are factored into the private class method [Check] (lines 41–47);
  • line 38: the [Identify] method from the previous examples has been replaced by the read-only property [Identity], which returns the person’s identity instead of displaying it.

We are creating a class named [Teacher] that inherits from the class [Person]: [Teacher.cs]:

namespace Inheritance1;

public class Teacher : Person
{
    // constructor
    public Teacher(string firstName, string lastName, int age, int section)
        : base(firstName, lastName, age)
    {
        // we store the section through the Section property
        Section = section;
        // tracing
        Console.WriteLine("Constructor Teacher(string, string, int, int)");
    }

    // Section property
    public int Section { get; set; }

    // Identity property: it hides (new) the one of the parent class
    public new string Identity => $"Teacher[{base.Identity},{Section}]";
}

The [Teacher] class adds to the methods and properties of the [Person] class:

  • line 3: the class [Teacher] derives from the class [Person];
  • line 16: a property [Section], which is the section number to which the teacher belongs within the teaching staff (roughly one section per subject);
  • lines 6–12: a constructor used to initialize all of a teacher’s properties;
  • line 19: a new property, [Identity], which will be discussed in more detail later.

4.2.2. Creating a Teacher Object

A child class does not inherit constructors from its parent class. It must define its own constructors. The constructor for the [Teacher] class is as follows:

    public Teacher(string firstName, string lastName, int age, int section)
        : base(firstName, lastName, age)
    {
        // we store the section through the Section property
        Section = section;
        // tracing
        Console.WriteLine("Constructor Teacher(string, string, int, int)");
    }

The declaration on lines 6–7 specifies that the constructor takes four parameters: [firstName], [lastName], [age], [section], and passes three ([firstName], [lastName], [age]) to its base class, in this case the class [Person]. We know that this class has a constructor, [Person(string, string, int)], which will allow us to create a person using the passed parameters. Once the construction of the [Person] part is complete, the construction of the [Teacher] object continues with the execution of the constructor’s body (line 10).

Note that on the left side of the = sign, the [Section] property is used. If this property performed validity checks, the constructor would benefit from them. This avoids having to place these checks in two different places: the constructor and the property.

In summary, the constructor of a derived class:

  • passes to its base class the parameters it needs to construct itself;
  • uses the remaining parameters to initialize its own members.

We could have chosen to write:

1
2
3
4
5
6
7
public Teacher(string firstName, string lastName, int age, int section)
{
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
    Section = section;
}

This is impossible, for two reasons. First, the class *[Person] does not have a parameterless constructor: in the absence of ***[: base(...)], the compiler attempts to call this constructor and reports an error. Second, even if the private fields [firstName], [lastName], and [age] existed (here, these are the hidden fields of the properties), only objects of the class [Person] have direct access to them. All other objects, including objects of derived classes as in this case, must access them through public (or protected) members. This would have been different if the class *[Person] had declared these fields as protected (*[protected]*): it would then have allowed derived classes to access them directly. Using the parent class’s constructor is therefore the correct solution and the standard approach: when constructing a child object, first call the parent object’s constructor, then complete the initializations specific to the child object ([Section]* in our example).

If the class [Teacher] did not define a property [Identity], the expression [e.Identity]—where [e] is a teacher—would refer to the property [Identity] inherited from the class [Person]: it is public and, through inheritance, becomes a public property of the class [Teacher]. This would then yield the identity of the [Person] part of the teacher, without its section.

4.2.3. Hiding a property or method: the new keyword

To obtain a teacher’s full identity, the class [Teacher] defines its own property [Identity]:

    // Identity property: it hides (new) the one of the parent class
    public new string Identity => $"Teacher[{base.Identity},{Section}]";

The property [Identity] of class [Teacher] relies on the property [Identity] of its parent class, designated as [base.Identity], to obtain the “Person” portion and then supplements it with the section, which is specific to the class [Teacher].

Consider a teacher object [E]. This object contains a [Person] object:

Image

The property [Identity] is defined in both the class [Teacher] and its parent class [Person]. In the child class [Teacher], the property [Identity] is preceded by the keyword [new] to indicate that a new property, [Identity], is intentionally defined for the class [Teacher] (without [new], the compiler issues a warning). The class [Teacher] now has two properties, [Identity]:

  • one inherited from the parent class [Person];
  • its own.

If [E] is a [Teacher] object, [E.Identity] refers to the property [Identity] of the class [Teacher]. It is said that the property [Identity] of the child class masks (hides) the property [Identity] of the parent class. Generally speaking, if [O] is an object and [M] is a method, to execute the method [O.M], the compiler searches for a method [M] in the following order:

  • in the class of the object [O];
  • in its parent class, if it has one;
  • in the parent class of its parent class, if it exists;
  • and so on.

Inheritance therefore allows the child class to redefine methods and properties with the same names as those in the parent class. This is what enables the child class to be adapted to its own needs.

The test program [Program.cs] for project [Inheritance1] is as follows:

using Inheritance1;

// a teacher
Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
// the Identity property of the Teacher class
Console.WriteLine(e.Identity);
// the Identity property of the Person class is still there
Console.WriteLine(((Person)e).Identity);
// a person
Person p = new Person("Jean", "Dupont", 30);
// the Display method receives a Person or an object of a derived class
Display(e);
Display(p);
// the ToString method inherited from the object class
Console.WriteLine(e.ToString());
Console.WriteLine(p.ToString());
Console.WriteLine($"Number of persons created: {Person.NbPersons}");

// displays the identity of p
static void Display(Person p)
{
    Console.WriteLine(p.Identity);
}
  • line 4: creates a teacher;
  • line 6: displays its identity, that of the class [Teacher];
  • line 8: the conversion operator [(Person)] treats the object [e] as a [Person]: the property [Identity] of the class [Person] is then used;
  • lines 12–13: call to the function [Display] (lines 20–22) with a teacher followed by a person. We’ll return to this in the next paragraph;
  • lines 15–16: call to the [ToString] method on both objects. We’ll also come back to this.

Results:

> dotnet run
Constructor Person(string, string, int)
Constructor Teacher(string, string, int, int)
Teacher[[Lucile, Dumas, 56],61]
[Lucile, Dumas, 56]
Constructor Person(string, string, int)
[Lucile, Dumas, 56]
[Jean, Dupont, 30]
Inheritance1.Teacher
Inheritance1.Person
Number of persons created: 2

We can see that:

  • an object [Person] (line 2) was created before the object [Teacher] (line 3);
  • [e.Identity] provided the teacher's full identity (line 4), and [((Person)e).Identity] provided the identity of its [Person] part (line 5).

4.2.4. Polymorphism

Consider a class hierarchy: C0 ← C1 ← C2 ← … ← Cn, where Ci ← Cj indicates that class Cj is derived from class Ci. This implies that class Cj has all the characteristics of class Ci plus additional ones. Let Oi be objects of type Ci. It is valid to write [Oi = Oj] with j > i. Indeed, by inheritance, class Cj has all the characteristics of class Ci plus additional ones. Therefore, an object Oj of type Cj contains an object of type Ci. The operation [Oi = Oj] causes Oi to be a reference to the object of type Ci contained within the object Oj.

The fact that a variable Oi of class Ci can actually reference not only an object of class Ci but any object derived from class Ci is called polymorphism: the ability of a variable to reference different types of objects.

Let’s take an example and consider the function [Display] from the previous program:

1
2
3
4
static void Display(Person p)
{
    Console.WriteLine(p.Identity);
}

We can pass it either a *[Person]* or a *[Teacher]: In the latter case, the formal parameter [p], which is of type [Person], receives a value of type [Teacher]. Since the type [Teacher] is derived from the type [Person]*, this is valid.

The property [p.Identity] returns a string identifying the [Person] object [p]. What happens if the parameter passed to the [Display] function is an object of type [Teacher]? The previous results show that: the [p.Identity] statement executed the [Identity] property of a [Person] each time, first the person contained in the teacher [e] (line 7), then the person [p] itself (line 8). It did not adapt to the object actually passed as a parameter to [Display]. We would have preferred to have the full identity of the teacher [e]. For that to happen, the notation [p.Identity] would have had to refer to the property [Identity] of the object actually pointed to by [p], rather than the property [Identity] of the “Person” part of that object. With masking ([new]), the property is selected at compile time, based on the declared type of the variable ([Person]).

4.2.5. Redefinition and Polymorphism: virtual and override

It is possible to achieve the desired result by declaring [Identity] as a virtual property ([virtual]) in the base class [Person]. This is what the [Inheritance2] project does; its [Person] class is the same as the one in the [Inheritance1] project, with two changes:

    // identity of the person: virtual property
    public virtual string Identity => $"[{FirstName}, {LastName}, {Age}]";

    // redefinition of the ToString method of the object class
    public override string ToString() => Identity;
  • Line 38: The keyword [virtual] makes [Identity] a virtual property. This keyword can also be applied to methods. Child classes that override a virtual property or method must then use the keyword [override] instead of [new] to qualify their overridden property or method;
  • line 41: a redefinition of the method [ToString], discussed in the following paragraph.

In the class [Teacher], the property [Identity] is redefined as follows:

namespace Inheritance2;

// a sealed class: no class can derive from it
public sealed class Teacher : Person
{
    // constructor
    public Teacher(string firstName, string lastName, int age, int section)
        : base(firstName, lastName, age)
    {
        Section = section;
        Console.WriteLine("Constructor Teacher(string, string, int, int)");
    }

    // Section property
    public int Section { get; set; }

    // Identity property: it redefines (override) the one of the parent class
    public override string Identity => $"Teacher[{base.Identity},{Section}]";
}
  • Line 18: The property [Identity] overrides the one in the parent class;
  • Line 4: The class is sealed ([sealed]), which we’ll explain in more detail later.

With a virtual property or method, the choice of which property is executed by [p.Identity] is made at runtime, based on the actual type of the object referenced by [p]. The program [Program.cs] from the project [Inheritance2] is as follows:

using Inheritance2;

// a teacher
Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
Display(e);
// a person
Person p = new Person("Jean", "Dupont", 30);
Display(p);
// Console.WriteLine(object) displays the result of the object's ToString method
Console.WriteLine(e);
Console.WriteLine(p);
// an array of persons of different types
Person[] persons = [p, e];
foreach (Person person in persons)
{
    Console.WriteLine($"{person.GetType().Name} : {person}");
}

// displays the identity of p
static void Display(Person p)
{
    Console.WriteLine(p.Identity);
}

Results:

> dotnet run
Constructor Person(string, string, int)
Constructor Teacher(string, string, int, int)
Teacher[[Lucile, Dumas, 56],61]
Constructor Person(string, string, int)
[Jean, Dupont, 30]
Teacher[[Lucile, Dumas, 56],61]
[Jean, Dupont, 30]
Person : [Jean, Dupont, 30]
Teacher : Teacher[[Lucile, Dumas, 56],61]

This time, on line 4, the function [Display] correctly displayed the teacher’s full name.

4.2.6. The ToString method

The class [object] (the C# alias for class [System.Object]) is the “parent” class of all C# classes. So when you write:

public class Person

you are implicitly writing:

public class Person : object

The [object] class defines a virtual method, [ToString], which by default returns the full class name of the object. This is what the last two lines of output from the [Inheritance1] project displayed, with the calls to [e.ToString()] and [p.ToString()]: [Inheritance1.Teacher] and [Inheritance1.Person]. Although we did not override the [ToString] method in the [Person] and [Teacher] classes, the [ToString] method of the [object] class was able to display the object’s actual class name.

In the [Inheritance2] project, the [Person] class redefines the [ToString] method (see above):

    public override string ToString() => Identity;

Let’s return to the program [Program.cs] in the project [Inheritance2]:

// Console.WriteLine(object) displays the result of the object's ToString method
Console.WriteLine(e);
Console.WriteLine(p);
// an array of persons of different types
Person[] persons = [p, e];
foreach (Person person in persons)
{
    Console.WriteLine($"{person.GetType().Name} : {person}");
}

In Line 10, the method [WriteLine] of class [Console] has no variant that accepts a parameter of type [Person]. Among the various variants of [WriteLine], there is one that accepts a parameter of type [object]. The compiler will use this method, [WriteLine(object o)], because this signature means that the parameter [o] can be of type [object] or a derived type. Since [object] is the superclass of all classes, any object can be passed as a parameter to [WriteLine]—including an object of type [Person] or [Teacher]. The [WriteLine(object o)] method writes [o.ToString()] to the console. Since the [ToString] method is virtual, the [ToString] method of the actual object is used—in this case, that of the [Person] class. This method returns [Identity], which is also a virtual property: For the teacher [e], it is therefore the property [Identity] of the class [Teacher] that is used, as shown in line 7 of the results. It is therefore unnecessary to redefine [ToString] in the class [Teacher], unlike in the 2008 course.

The same mechanism applies to string interpolation: in [$"{person}"] (line 16), the object [person] is converted to a string by its method [ToString]. The loop on lines 14–16 iterates over an array of references to [Person], which actually point to objects of different types: the method [GetType] (which is also inherited from [object]) returns the actual type of the object, whose property [Name] is the name.

4.2.7. Sealed classes

The class [Teacher] in the [Inheritance2] project is declared as [public sealed class Teacher : Person]. The keyword [sealed] prevents classes from being derived from the class [Teacher]: the declaration [class TeacherResearcher : Teacher] would be rejected by the compiler. A class is sealed when it is not intended to be derived from; the compiler and the runtime can also optimize calls to its virtual methods. The keyword also applies to a redefined method or property: [public sealed override string Identity] would prevent derived classes from redefining it again, without prohibiting derivation from the class.

Conversely, a method or property that is not declared as [virtual], [abstract] (see below), or [override] cannot be overridden with [override]: in C#, unlike in Java, methods are not virtual by default.

4.3. Redefining the meaning of an operator for a class

4.3.1. Introduction

Consider the expression [op1 + op2], where [op1] and [op2] are two operands. It is possible to redefine the meaning of the operator [+]. If the operand [op1] is an instance of class [C1], a static method must be defined in class [C1] with the following signature:

public static Type operator +(C1 operand1, C2 operand2)

When the compiler encounters the expression [op1 + op2], it translates it to [C1.operator +(op1, op2)]. The type returned by the method [operator] is important. In fact, consider the operation [op1 + op2 + op3]. It is translated by the compiler as [(op1 + op2) + op3]. Let [res12] be the result of [op1 + op2]. The next operation performed is [res12 + op3]. If [res12] is of type [C1], it will also be translated to [C1.operator +(res12, op3)]. This allows operations to be chained together.

Unary operators with only one operand can also be redefined. Thus, if [op1] is an object of type [C1], the operation [op1++] can be redefined by a static method of the [C1] class:

public static C1 operator ++(C1 operand1)

What has been stated here applies to most operators, with a few exceptions:

  • Operators [==] and [!=] must be redefined together, as must [<] and [>], [<=] and [>=];
  • the operators [&&], [||], [=], [.], [?:], [??], and [new] cannot be redefined;
  • up to C# 13, the compound assignment operators ([+=], [-=], …) could not be overridden: [a += b] was always compiled to [a = a + b]. C# 14 allows them to be overridden, as we will see.

4.3.2. An example

The [Lists] project in the solution defines a class named [ListOfPersons], which is a list of people. The classes [Person] and [Teacher] in this project are the same as those in the [Inheritance2] project, but without their tracking messages and the person counter. The 2008 course had the class *[ListOfPersons] derived from the class *[ArrayList]*, a list of objects of type *[object], which is now obsolete. We use the generic list [List<Person>] (generic classes are discussed later in this chapter; the class [List<T>] is covered in the next chapter) and, rather than inheriting from it, we place it in a private field of the class. This is referred to as using composition rather than inheritance: the class [ListOfPersons] thus exposes only the operations it has chosen. From the class [List<Person>], we use only the following elements:

  • the method [L.Add(p)], which adds the person [p] to the list [L], and the method [L.AddRange(L2)], which adds all the elements of the list [L2] to it;
  • the property [L.Count], which returns the number of elements in the list [L];
  • The notation [L[i]], which refers to item No. i in the list [L].

The definition of class [ListOfPersons] is as follows: [ListOfPersons.cs]:

namespace Lists;

public class ListOfPersons
{
    // the persons of the list
    private readonly List<Person> persons = [];

    // number of persons in the list
    public int Count => persons.Count;

    // + operator: returns a new list containing the persons of l and the person p
    public static ListOfPersons operator +(ListOfPersons l, Person p)
    {
        ListOfPersons result = new ListOfPersons();
        result.persons.AddRange(l.persons);
        result.persons.Add(p);
        return result;
    }

    // compound assignment operator += (C# 14): adds p to the current list
    public void operator +=(Person p)
    {
        persons.Add(p);
    }

    // indexer by position, read and write
    public Person this[int i]
    {
        get { return persons[i]; }
        set { persons[i] = value; }
    }

    // indexer by last name, read-only: returns the position of the person or -1
    public int this[string lastName]
    {
        get
        {
            // we search for the person
            for (int i = 0; i < persons.Count; i++)
            {
                if (persons[i].LastName == lastName)
                {
                    return i;
                }
            }
            return -1;
        }
    }

    // ToString: returns (person1,person2,...)
    public override string ToString()
    {
        return "(" + string.Join(",", persons) + ")";
    }
}
  • line 6: the list of people. The keyword [readonly] indicates that the field can only be assigned at the time of its declaration or within a constructor: the list itself may change in content, but the field will always refer to the same list. [] is an empty collection expression: the type of the created collection, [List<Person>], is inferred from the field’s type;
  • line 9: the number of people, a read-only property;
  • lines 12–17: definition of the operator [+] for the operation [l + p], where [l] is of type [ListOfPersons] and [p] is of type [Person] or a derived type. The operator creates a new list (line 14), copies the people from [l] into it (line 15), and then adds the person [p] (line 16). As with the [+] operator for numbers or strings, the operands are not modified. Since the list [l] is of the same class, the operator has access to its private field [l.persons]. The resulting list is of type [ListOfPersons], which allows the [+] operators to be chained together as in [l + p1 + p2], interpreted (operator precedence) as [(l + p1) + p2];
  • Lines 21–23: definition of the operator [+=] (C# 14). Unlike the other operators, this is not a static method but an instance method with no return value ([void]): it modifies the current object, in this case by adding the person [p] to it;
  • lines 51–53: we redefine the method [ToString] to display a list of people in the form (person1, person2, ...), where personi is itself the result of the person’s [ToString] method. The static method [string.Join(separator, elements)] concatenates the textual representations of the elements, separating them with the separator. The 2008 course built this string manually using a [StringBuilder] object.

Note that the [ListOfPersons] class has no constructor. It therefore uses the default constructor [public ListOfPersons()], which does nothing other than initialize the [persons] field with the value specified in its declaration. Lines 26–46 are discussed in the following section.

The test program [Program.cs] begins as follows:

using Lists;

// --- the + operator
// a list of persons
ListOfPersons l = new ListOfPersons();
// adding persons
l = l + new Person("jean", "martin", 10) + new Person("pauline", "leduc", 12);
// display
Console.WriteLine("l=" + l);
// l + p returns a new list: l is not modified
ListOfPersons l2 = l + new Person("louis", "durand", 8);
Console.WriteLine("l=" + l);
Console.WriteLine("l2=" + l2);

// --- the += operator (C# 14)
ListOfPersons l3 = l;
// += adds the teacher to the list referenced by l, without creating a new list
l += new Teacher("camille", "germain", 27, 60);
Console.WriteLine("l=" + l);
// l and l3 still reference the same object
Console.WriteLine($"l3=l : {ReferenceEquals(l, l3)}");
  • line 5: creation of an empty list of people [l];
  • line 7: adds two people using the [+] operator;
  • line 11: [l + p] creates a new list [l2]; the list [l] remains unchanged;
  • line 18: adds a teacher using the [+=] operator;
  • Lines 9–13: Concatenating a string and an object uses the [ToString] method, which is redefined from the [ListOfPersons] class.

Results:

1
2
3
4
5
6
> dotnet run
l=([jean, martin, 10],[pauline, leduc, 12])
l=([jean, martin, 10],[pauline, leduc, 12])
l2=([jean, martin, 10],[pauline, leduc, 12],[louis, durand, 8])
l=([jean, martin, 10],[pauline, leduc, 12],Teacher[[camille, germain, 27],60])
l3=l : True

4.3.3. Compound assignment operators (C# 14)

Why define the operator [+=] when [l = l + p] produces the same output? If the class does not define [+=], the compiler translates [l += p] to [l = l + p]: a new list is created, all the people from [l] are copied into it, then [l] refers to this new list, and the old one is discarded by the garbage collector. With the operator [+=] defined by the class, the person is added to the existing list without being copied. Line 16 stores a second reference, [l3], to the list [l]; after the [l += ...] statement, both references still point to the same object (line 6 of the results), which proves that the list was not recreated. For a large list, or in a loop, the difference in performance is significant.

4.4. Defining an indexer for a class

Here, we continue to use the class [ListOfPersons]. If [l] is an instance of [ListOfPersons], we want to be able to use the notation [l[i]] to refer to person number i in the list [l], both for reading ([Person p = l[i]]) and writing ([l[i] = new Person(...)]).

To be able to write [l[i]], where [l[i]] refers to an object [Person], we need to define a specific property named [this] in the [ListOfPersons] class:

    // indexer by position, read and write
    public Person this[int i]
    {
        get { return persons[i]; }
        set { persons[i] = value; }
    }

    // indexer by last name, read-only: returns the position of the person or -1
    public int this[string lastName]
    {
        get
        {
            // we search for the person
            for (int i = 0; i < persons.Count; i++)
            {
                if (persons[i].LastName == lastName)
                {
                    return i;
                }
            }
            return -1;
        }
    }

The property [this[int i]] is called an indexer because it gives meaning to the expression [obj[i]], which resembles array notation, whereas [obj] is not an array but an object. The method [get] of the indexer of the object [obj] is called when [variable = obj[i]] is written, and the method [set] is called when [obj[i] = value] is written.

  • Lines 27–30: The position-based indexer. The method [get] returns person No. i from the private list [persons], and the method [set] assigns person [value] to it. The 2008 course, whose class was derived from [ArrayList], had to mask the parent class’s indexer with the keyword [new] and convert the element of type [object] that it returned to [Person]. With a generic list, no more conversion is needed;
  • lines 34–46: we also want to be able to write [int i = l["lastName"]], that is, to index the list [l] not by an element number but by a person’s name. The result of [l[lastName]] is an integer: the position in the list of the person with the name [lastName], or -1 if that person is not in the list. Only the method [get] is defined, thereby preventing the syntax [l["lastName"] = value], which would have required the definition of a method [set]. A class can have multiple indexers, provided their parameters differ.

The test program continues as follows:

// --- indexers
// change of element no. 1
l[1] = new Person("franck", "gallon", 5);
// display of element no. 1
Console.WriteLine("l[1]=" + l[1]);
// display of the list l
Console.WriteLine("l=" + l);
// search for persons by their last name
string[] lastNames = ["martin", "germain", "xx"];
foreach (string lastName in lastNames)
{
    int index = l[lastName];
    if (index != -1)
    {
        Console.WriteLine($"Person({lastName})={l[index]}");
    }
    else
    {
        Console.WriteLine($"Person({lastName}) does not exist");
    }
}
  • line 25: call to the method [set] of the indexer [this[int]];
  • line 27: call to its method [get];
  • Line 34: Call to the [this[string]] indexer;
  • line 37: call to the [this[int]] indexer.

Results:

l[1]=[franck, gallon, 5]
l=([jean, martin, 10],[franck, gallon, 5],Teacher[[camille, germain, 27],60])
Person(martin)=[jean, martin, 10]
Person(germain)=Teacher[[camille, germain, 27],60]
Person(xx) does not exist

4.5. Structures

4.5.1. Value and Reference

The C# structure is analogous to the C language structure and is very similar to the concept of a class. A structure is defined as follows:

struct StructureName
{
    // fields
    ...
    // properties
    ...
    // constructors
    ...
    // methods
    ...
}

Despite similarities in their declarations, there are significant differences between classes and structures. For example, the concept of inheritance does not exist with structures (a structure can, however, implement interfaces, which are discussed later). If we are writing a class that is not meant to be derived from, what are the differences between a structure and a class that will help us choose between the two? Let’s use the following example to find out: [classes-13.cs]:

// structure and class: value copy, reference copy
// a structure sp1
SPerson sp1;
sp1.Name = "paul";
sp1.Age = 10;
Console.WriteLine("sp1=SPerson(" + sp1.Name + "," + sp1.Age + ")");
// a structure sp2
SPerson sp2 = sp1;
Console.WriteLine("sp2=SPerson(" + sp2.Name + "," + sp2.Age + ")");
// sp2 is modified
sp2.Name = "nicole";
sp2.Age = 30;
// checking sp1 and sp2
Console.WriteLine("sp1=SPerson(" + sp1.Name + "," + sp1.Age + ")");
Console.WriteLine("sp2=SPerson(" + sp2.Name + "," + sp2.Age + ")");

// an object op1
CPerson op1 = new CPerson();
op1.Name = "paul";
op1.Age = 10;
Console.WriteLine("op1=CPerson(" + op1.Name + "," + op1.Age + ")");
// an object op2
CPerson op2 = op1;
Console.WriteLine("op2=CPerson(" + op2.Name + "," + op2.Age + ")");
// op2 is modified
op2.Name = "nicole";
op2.Age = 30;
// checking op1 and op2
Console.WriteLine("op1=CPerson(" + op1.Name + "," + op1.Age + ")");
Console.WriteLine("op2=CPerson(" + op2.Name + "," + op2.Age + ")");

// SPerson structure
struct SPerson
{
    public string Name;
    public int Age;
}

// CPerson class
class CPerson
{
    public string Name = "";
    public int Age;
}
  • lines 33–36: a structure with two public fields: [Name], [Age];
  • lines 40–43: a class with two public fields: [Name], [Age]. Public fields, which are generally discouraged, make this example more readable.

Results:

1
2
3
4
5
6
7
8
9
> dotnet run classes-13.cs
sp1=SPerson(paul,10)
sp2=SPerson(paul,10)
sp1=SPerson(paul,10)
sp2=SPerson(nicole,30)
op1=CPerson(paul,10)
op2=CPerson(paul,10)
op1=CPerson(nicole,30)
op2=CPerson(nicole,30)

This structure does not have a constructor here. It could have one, as we will show later. By default, it always has a parameterless constructor—in this case, [SPerson()]—which sets all its fields to zero ([null] for the references).

The declaration on line 3 creates a structure ([Name], [Age]), and the value of [sp1] is this structure itself. Its fields do not yet have values: the compiler requires that they all be assigned (lines 4–5) before the structure is used (line 6). In the case of a class, the object ([Name], [Age]) must be created explicitly using the [new] operator (line 18). The statement creates a [CPerson] object (roughly equivalent to our structure), and the value of [op1] is then the address (the reference) of that object.

To summarize:

  • in the case of the structure, the value of [sp1] is the structure itself;
  • in the case of the class, the value of [op1] is the address of the created object.

Image

When [SPerson sp2 = sp1;] is written in the program (line 8), a new structure [sp2] ([Name], [Age]) is created and initialized with the value of [sp1], which is the structure itself:

Image

The structure of [sp1] is duplicated in [sp2] (left side of the diagram). This is a value copy. Now let’s consider the instruction [CPerson op2 = op1;] (line 23). In the case of classes, the value of [op1] is copied to [op2], but since this value is actually the object’s address, the object itself is not duplicated (right side of the diagram).

In the case of the structure, changing the value of [sp2] does not change the value of [sp1], as shown by the program (lines 4–5 of the results). In the case of the object, if you modify the object pointed to by [op2], the one pointed to by [op1] is also modified since they are the same (lines 8–9).

We can therefore conclude from these explanations that:

  • the value of a variable of type structure is the structure itself;
  • the value of a variable of type class is the address of the object it points to.

It is said that structures are value types and classes are reference types. The simple types in C# ([int], [double], [decimal], [bool], [char], …) are .NET structures ([System.Int32], [System.Double], …), whereas [string] and arrays are reference types. A structure is suitable for representing a small piece of data (a point, a color, a date, an amount…) that is treated as a value. A structure is not allocated individually on the garbage collector’s heap: a local variable of type structure is generally placed on the stack, and an array of structures contains the structures themselves rather than references to them.

4.5.2. A Structure with Properties and Constructors

Once the fundamental difference between a value and a reference is understood, a structure proves to be very similar to a class, as shown in the following example [classes-14.cs]:

// a structure with constructors, properties and a method
// a person p1
SPerson p1 = new SPerson();
Console.WriteLine($"p1={p1}");
p1.Name = "paul";
p1.Age = 10;
Console.WriteLine($"p1={p1}");
// a person p2
SPerson p2 = p1;
Console.WriteLine("p2=" + p2);
// p2 is modified
p2.Name = "nicole";
p2.Age = 30;
// checking p1 and p2
Console.WriteLine("p1=" + p1);
Console.WriteLine("p2=" + p2);
// a person p3
SPerson p3 = new SPerson("amandin", 18);
Console.WriteLine("p3=" + p3);
// a person p4
SPerson p4 = new SPerson { Name = "x", Age = 10 };
Console.WriteLine("p4=" + p4);
// the default value of a structure: all fields set to zero (null for references)
SPerson p5 = default;
Console.WriteLine("p5=" + p5);
// equality of two structures: comparison of the values of the fields
SPerson p6 = new SPerson("nicole", 30);
Console.WriteLine($"p1.Equals(p2) : {p1.Equals(p2)}, p2.Equals(p6) : {p2.Equals(p6)}");

// SPerson structure
struct SPerson
{
    // constructors
    public SPerson(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public SPerson() : this("?", 0)
    {
    }

    // properties
    public string? Name { get; set; }
    public int Age { get; set; }

    // ToString
    public override string ToString()
    {
        return $"SPerson({Name},{Age})";
    }
}
  • lines 34–37: a constructor with two parameters. The 2008 course explained that a struct constructor could not initialize fields via properties: this restriction has been removed (C# 11);
  • line 40: Since C# 10, a struct can declare its own parameterless constructor, which is chained to the previous one here. It is called by [new SPerson()] (line 3) and by the object initializer on line 21, which then modifies the two properties;
  • lines 45–46: two automatic properties. The property [Name] is declared as type [string?] (a string that could be [null]) because a struct can always be created without using a constructor: this is the case for its default value (line 24), where all fields are set to zero and the name is therefore [null], as well as for the elements of a newly created array of structures;
  • lines 49–51: redefinition of the method [ToString]. A structure implicitly derives from the class [System.ValueType], which itself derives from [object];
  • line 28: The [Equals] method, inherited from [ValueType], compares two structures field by field. The [==] operator, on the other hand, is not defined for a structure unless it is overridden.

Results:

> dotnet run classes-14.cs
p1=SPerson(?,0)
p1=SPerson(paul,10)
p2=SPerson(paul,10)
p1=SPerson(paul,10)
p2=SPerson(nicole,30)
p3=SPerson(amandin,18)
p4=SPerson(x,10)
p5=SPerson(,0)
p1.Equals(p2) : False, p2.Equals(p6) : True

At the end of the program, the structures [p1] and [p2] are different (lines 5–6): with a class, [p1] and [p2] would have referred to the same object.

4.5.3. Immutable Structures and Record Structures

A structure whose state can be modified can lead to unexpected results: since it is copied with every assignment or parameter pass, one often modifies a copy while believing one is modifying the original. Therefore, it is recommended to make structures immutable: once created, they can no longer be modified. The program [classes-15.cs] demonstrates both ways to do this:

// immutable structures (readonly struct) and record structures (record struct)
Point a = new Point(1, 2);
// a.X = 5;   // rejected: the properties of a readonly struct cannot be modified
Point b = a.Move(3, 4);
Console.WriteLine($"a={a}, b={b}");

// a record struct: ToString, Equals, == and != are generated by the compiler
PointR r1 = new PointR(1, 2);
PointR r2 = new PointR(1, 2);
Console.WriteLine($"r1={r1}, r2={r2}");
Console.WriteLine($"r1 == r2 : {r1 == r2}");
// a (non readonly) record struct can be modified
r2.Y = 20;
Console.WriteLine($"r2={r2}, r1 == r2 : {r1 == r2}");
// the with expression creates a modified copy
PointR r3 = r1 with { X = 10 };
Console.WriteLine($"r3={r3}");

// a readonly record struct is immutable
Color red = new Color(255, 0, 0);
Color pink = red with { G = 128, B = 128 };
Console.WriteLine($"red={red}, pink={pink}");
// pink.G = 0;   // rejected: the structure is read-only

// an immutable structure
readonly struct Point
{
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public int X { get; }
    public int Y { get; }

    // we do not modify the point: we return a new one
    public Point Move(int dx, int dy) => new Point(X + dx, Y + dy);

    public override string ToString() => $"({X},{Y})";
}

// a "positional" record structure: X and Y are public properties
record struct PointR(int X, int Y);

// an immutable record structure
readonly record struct Color(byte R, byte G, byte B);
  • lines 26–40: a structure declared as [readonly] (C# 7.2). The compiler verifies that all its fields and properties are read-only. To “move” a point, the method [Move] returns a new point (line 38);
  • line 44: a record structure (C# 10). This single line declares a structure whose constructor has two parameters, [X] and [Y], and which has two public read-write properties, [X] and [Y]. The compiler also generates a method named [ToString] that displays the structure’s name and its properties, the equality methods and operators [==] and [!=] (line 11), as well as support for the expression [with], which creates a copy of the structure by modifying certain properties (line 16);
  • line 47: an immutable record structure: its properties are read-only (more precisely, [init]: they can only be set at creation), while the expression [with] remains valid (line 21).

Results:

1
2
3
4
5
6
7
> dotnet run classes-15.cs
a=(1,2), b=(4,6)
r1=PointR { X = 1, Y = 2 }, r2=PointR { X = 1, Y = 2 }
r1 == r2 : True
r2=PointR { X = 1, Y = 20 }, r1 == r2 : False
r3=PointR { X = 10, Y = 2 }
red=Color { R = 255, G = 0, B = 0 }, pink=Color { R = 255, G = 128, B = 128 }

The records ([record]) also exist as classes: we’ll examine them in detail a little later. For now, let’s just note that a [readonly record struct] is the most concise way to declare a small, immutable value-type data item: this is the type we’ll use for the tax brackets in this chapter’s example application.

4.6. Interfaces

4.6.1. A First Example

An interface is a set of method or property declarations that forms a contract. A class (or structure) that chooses to implement an interface commits to providing an implementation of all the methods and properties defined in the interface. The compiler verifies this implementation.

For example, here is the definition of the [System.Collections.IEnumerator] interface from .NET:

1
2
3
4
5
6
7
8
public interface IEnumerator
{
    // properties
    object Current { get; }
    // methods
    bool MoveNext();
    void Reset();
}

The properties and methods of the interface are defined only by their signatures. They are not implemented (they have no code). It is the classes that implement the interface that provide code for the interface’s methods and properties:

1
2
3
4
5
6
7
public class C : IEnumerator
{
    ...
    public object Current { get { ... } }
    public bool MoveNext() { ... }
    public void Reset() { ... }
}
  • Line 1: The class [C] implements the interface [IEnumerator]. Note that the symbol [:] used for implementing an interface is the same as the one used for deriving a class;
  • Lines 4–6: The implementation of the methods and properties of the [IEnumerator] interface. They must be public.

By convention, an interface name begins with the letter I. The following examples make up the [Statistics] project in the solution. Consider the following interface, [IStats.cs]:

namespace Statistics;

public interface IStats
{
    // average of a series of values
    double Average { get; }

    // standard deviation of the series
    double StandardDeviation();
}

The [IStats] interface has:

  • a read-only property [Average]: to calculate the average of a series of values;
  • a method [StandardDeviation]: to calculate the standard deviation.

Note that it is not specified anywhere which set of values is being referred to. It could be the average grades of a class, the monthly average sales of a particular product, the average temperature in a given location… This is the principle behind interfaces: they assume the existence of methods in the object but not that of specific data.

A first implementation class for the [IStats] interface could be a class used to store the grades of students in a class for a given subject. A student is characterized by the following [Student] structure: [Student.cs]:

1
2
3
4
namespace Statistics;

// a student
public readonly record struct Student(string FirstName, string LastName);

The student is identified by their first and last names. The 2008 course defined a structure with two automatic properties; an immutable record structure, as shown in the previous paragraph, is represented in a single line. A grade is characterized by the following structure: [Grade] [Grade.cs]:

1
2
3
4
5
6
7
namespace Statistics;

// the grade of a student
public readonly record struct Grade(Student Student, double Value)
{
    public override string ToString() => $"[{Student.FirstName},{Student.LastName},{Value}]";
}

The grade is defined by the student being graded and the grade itself. The structure redefines the method [ToString] generated by the compiler to display a grade in the form [firstName,lastName,value].

The grades of all students in a given subject are grouped in the following class [ArrayOfGrades]: [ArrayOfGrades.cs]:

namespace Statistics;

public class ArrayOfGrades : IStats
{
    // properties
    public string Subject { get; }
    public Grade[] Grades { get; }
    public double Average { get; }
    // field
    private readonly double standardDeviation;

    // constructor
    public ArrayOfGrades(string subject, Grade[] grades)
    {
        // storing the data
        Subject = subject;
        Grades = grades;
        // computing the average of the grades
        double sum = 0;
        foreach (Grade grade in Grades)
        {
            sum += grade.Value;
        }
        Average = Grades.Length != 0 ? sum / Grades.Length : -1;
        // computing the standard deviation
        double squares = 0;
        foreach (Grade grade in Grades)
        {
            squares += Math.Pow(grade.Value - Average, 2);
        }
        standardDeviation = Grades.Length != 0 ? Math.Sqrt(squares / Grades.Length) : -1;
    }

    // implementation of the StandardDeviation method of the IStats interface
    public double StandardDeviation()
    {
        return standardDeviation;
    }

    // ToString
    public override string ToString()
    {
        return $"subject={Subject}, grades=({string.Join(",", Grades)})";
    }
}
  • Line 3: The class [ArrayOfGrades] implements the interface [IStats]. It must therefore implement the property [Average] and the method [StandardDeviation]. These are implemented on line 8 ([Average]) and lines 35–37 ([StandardDeviation]);
  • lines 6–8: three read-only automatic properties: the subject for which the object stores grades, the grade array, and the grade average;
  • line 10: a field storing the standard deviation of the grades;
  • lines 13–31: the constructor. Here, we assume that the grades passed to the constructor will not change afterward. Therefore, we use the constructor to immediately calculate the average and standard deviation of these grades and store them. A read-only property such as [Average] can be assigned a value in the constructor (line 24); it cannot be assigned a value afterward. The expressions [condition ? value1 : value2] set the mean and standard deviation of an empty array to -1;
  • lines 41–43: the method [ToString] uses [string.Join] to concatenate the grades, each of which is converted to a string by the method [ToString] of the structure [Grade].

In the previous class, grades were stored in an array. It was not possible to add a new grade after the [ArrayOfGrades] object was created. We now offer a second implementation of the [IStats] interface, called [ListOfGrades], where this time the grades are stored in a list [List<Grade>], with the ability to add grades after the initial creation of the [ListOfGrades] object: [ListOfGrades.cs]:

namespace Statistics;

public class ListOfGrades : IStats
{
    // properties
    public string Subject { get; }
    public List<Grade> Grades { get; }
    // stored average and standard deviation (null: not yet computed)
    private double? average;
    private double? standardDeviation;

    // constructor
    public ListOfGrades(string subject, List<Grade> grades)
    {
        Subject = subject;
        Grades = grades;
    }

    // adding a grade
    public void Add(Grade grade)
    {
        Grades.Add(grade);
        // the average and the standard deviation will have to be recomputed
        average = null;
        standardDeviation = null;
    }

    // average of the grades: computed on first request
    public double Average
    {
        get
        {
            average ??= ComputeAverage();
            return average.Value;
        }
    }

    // standard deviation of the grades: computed on first request
    public double StandardDeviation()
    {
        standardDeviation ??= ComputeStandardDeviation();
        return standardDeviation.Value;
    }

    private double ComputeAverage()
    {
        if (Grades.Count == 0)
        {
            return -1;
        }
        double sum = 0;
        foreach (Grade grade in Grades)
        {
            sum += grade.Value;
        }
        return sum / Grades.Count;
    }

    private double ComputeStandardDeviation()
    {
        if (Grades.Count == 0)
        {
            return -1;
        }
        double m = Average;
        double squares = 0;
        foreach (Grade grade in Grades)
        {
            squares += Math.Pow(grade.Value - m, 2);
        }
        return Math.Sqrt(squares / Grades.Count);
    }

    // ToString
    public override string ToString()
    {
        return $"subject={Subject}, grades=({string.Join(",", Grades)})";
    }
}
  • Line 3: The class [ListOfGrades] implements the interface [IStats];
  • line 7: Grades are now stored in a list rather than an array;
  • lines 20–25: You can now add a grade to those already stored. The mean and standard deviation are no longer calculated in the constructor but on demand, within the interface members themselves: [Average] (lines 29–34) and [StandardDeviation] (lines 39–42). Once calculated, these values are stored so they aren’t recalculated with every request: they are said to be cached. Adding a score invalidates the cache;
  • lines 9–10: the cache. The type [double?] (C# 2) is a nullable value type: a variable of this type contains a [double] or [null], which here means “not yet calculated.” The 2008 course used the value -1 for this;
  • line 33: the [??=] operator (C# 8) only assigns a value to the left-hand variable if it is [null]. The method [ComputeAverage] is therefore called only if the average has not yet been calculated. The property [Value] of a variable [double?] returns the value [double] that it contains.

A test program could look like this: [Program.cs]:

using Statistics;

// a few students and their English grades
Student[] students = [new Student("Paul", "Martin"), new Student("Maxime", "Germain"), new Student("Berthine", "Samin")];
Grade[] grades = [new Grade(students[0], 14), new Grade(students[1], 16), new Grade(students[2], 18)];
// which we store in an ArrayOfGrades object
ArrayOfGrades english = new ArrayOfGrades("english", grades);
// display of average and standard deviation
DisplayStats(english);
// the same grades in a ListOfGrades object
ListOfGrades french = new ListOfGrades("french", new List<Grade>(grades));
DisplayStats(french);
// we add a grade
french.Add(new Grade(new Student("Jérôme", "Jaric"), 10));
DisplayStats(french);
// a variable can have an interface as its type
IStats stats = english;
Console.WriteLine($"English average: {stats.Average}");
stats = french;
Console.WriteLine($"French average: {stats.Average}");

// display of the average and standard deviation of an object implementing IStats
static void DisplayStats(IStats values)
{
    Console.WriteLine($"{values}, Average={values.Average}, Standard deviation={values.StandardDeviation()}");
}
  • line 4: creates an array of students;
  • line 5: creation of an array of grades;
  • line 7: a [ArrayOfGrades] object, whose mean and standard deviation are displayed on line 9;
  • line 11: an object [ListOfGrades], whose mean and standard deviation are displayed on line 12. The class [List<Grade>] has a constructor that accepts a collection of grades, in this case the array [grades];
  • line 14: a new grade is added, followed by a new display;
  • lines 23–25: The function [DisplayStats] takes a [IStats] type as a parameter, which is an interface type. This means that the actual parameter can be any object that implements the [IStats] interface. When using a data object of an interface type, only the methods of the interface implemented by that object are used. The rest is ignored. This property is similar to the polymorphism we saw with classes. If a set of classes Ci that are not related to each other through inheritance (and therefore cannot use inheritance-based polymorphism) share a set of methods with the same signature, it may be useful to group these methods into an interface I that all the relevant classes would implement. Instances of these classes Ci can then be used as actual parameters of functions that accept a formal parameter of type I—that is, functions that use only the methods of the Ci objects defined in the interface I, and not the specific fields and methods of the various Ci classes;
  • lines 17–19: A variable can be of the type of an interface. It can then refer to any object of a class that implements the interface. The compiler allows access only to the members of the interface: the property [Average] and the method [StandardDeviation].

Results:

1
2
3
4
5
6
> dotnet run
subject=english, grades=([Paul,Martin,14],[Maxime,Germain,16],[Berthine,Samin,18]), Average=16, Standard deviation=1,632993161855452
subject=french, grades=([Paul,Martin,14],[Maxime,Germain,16],[Berthine,Samin,18]), Average=16, Standard deviation=1,632993161855452
subject=french, grades=([Paul,Martin,14],[Maxime,Germain,16],[Berthine,Samin,18],[Jérôme,Jaric,10]), Average=14,5, Standard deviation=2,958039891549808
English average: 16
French average: 14,5

Finally, note that interfaces can have multiple implementations; that is, you can write:

1
2
3
4
public class DerivedClass : BaseClass, I1, I2, ..., In
{
    ...
}

where the Ij are interfaces: a class inherits from only one class, but can implement as many interfaces as it wishes.

4.6.2. Default Interface Methods

Starting with C# 8, an interface can provide a default implementation for some of its methods. A class that implements the interface can then use this implementation or provide its own. This makes it possible, for example, to add a method to an already published interface without requiring all classes that implement it to be modified. The program [classes-16.cs] provides an example:

// default interface methods (C# 8)
IShape[] shapes = [new Square(2), new Circle(1)];
foreach (IShape shape in shapes)
{
    Console.WriteLine(shape.Description());
}
// the default method is only accessible through the interface type
Square square = new Square(3);
// Console.WriteLine(square.Description());   // rejected: Square does not define Description
Console.WriteLine(((IShape)square).Description());

public interface IShape
{
    // method to implement
    double Area();

    // method with a default implementation
    string Description() => $"{GetType().Name} with area {Area():F2}";
}

// Square uses the default implementation of Description
public class Square : IShape
{
    private readonly double side;

    public Square(double side)
    {
        this.side = side;
    }

    public double Area() => side * side;
}

// Circle provides its own implementation of Description
public class Circle : IShape
{
    private readonly double radius;

    public Circle(double radius)
    {
        this.radius = radius;
    }

    public double Area() => Math.PI * radius * radius;

    public string Description() => $"Circle with radius {radius} and area {Area():F2}";
}
  • Lines 12–18: The [IShape] interface declares a method, [Area], to be implemented, and a method, [Description], with a default implementation. This method can use the other members of the interface ([Area]) and those of [object] ([GetType]). The [{Area():F2}] format displays the area with two decimal places;
  • lines 22–31: The class [Square] implements only [Area]; it uses the method [Description] by default;
  • lines 35–46: The class [Circle] provides its own method, [Description];
  • line 10: the default method belongs to the interface, not to the [Square] class: it can only be called through a reference of type [IShape]. The call to [square.Description()] is rejected by the compiler.

Results:

1
2
3
4
> dotnet run classes-16.cs
Square with area 4,00
Circle with radius 1 and area 3,14
Square with area 9,00

Finally, starting with C# 11, an interface can declare abstract static members that the types implementing it must provide. These are used only with generics: we’ll cover them at the end of the section on generics.

4.7. Abstract Classes

An abstract class is a class that cannot be instantiated. You must create derived classes, which can be instantiated. Abstract classes can be used to refactor the code of a class hierarchy. Let’s examine the following example, which forms the [Users] project of the solution: [User.cs]:

namespace Users;

public abstract class User
{
    // fields
    private readonly string login;
    private readonly string password;
    private readonly string role;

    // constructor
    protected User(string login, string password)
    {
        // we store the information
        this.login = login;
        this.password = password;
        // we identify the user
        string? role = Identify();
        // identified?
        if (role == null)
        {
            throw new UnknownUserException($"[{login},{password}]");
        }
        this.role = role;
    }

    // ToString
    public override string ToString()
    {
        return $"User[{login},{password},{role}]";
    }

    // identification: returns the role of the user or null if they are not recognized
    protected abstract string? Identify();
}
  • line 3: the class is declared abstract using the keyword [abstract];
  • Lines 11–23: the constructor for the [User] class. This class stores information about a web application’s user. The application has various types of users who are authenticated via a username and password (lines 6–7). These two pieces of information are verified against a directory (LDAP) for some users, against a database for others, and so on. The constructor is [protected]: it can only be called by the constructors of derived classes;
  • lines 14–15: the authentication information is stored;
  • line 17: it is verified by a method named [Identify]. Because the authentication method is not known at this level, it is declared abstract (line 33) using the keyword [abstract]. An abstract method has no body: derived classes will provide one. A class that has an abstract method must itself be declared abstract. The method [Identify] returns a string specifying the user’s role (basically, what they are authorized to do), or [null] if the user is not recognized: its return type is therefore [string?];
  • lines 19–21: if the user is not recognized, an exception is thrown;
  • lines 27–29: the method [ToString], which identifies an instance of the class.

We assume here that the developer wants to have control over the construction of instances of the class [User] and its derived classes, perhaps because they want to ensure that an exception of a certain type is thrown if the user is not recognized. Derived classes can rely on this constructor. To do so, they must provide the method [Identify]. Note that the constructor here calls a method overridden by the derived classes even though those classes have not yet executed their own constructors: the method [Identify] must therefore not use any fields of the derived class, which have not yet been initialized. This is a practice that should be used with caution.

The [UnknownUserException] class is as follows:

1
2
3
4
5
6
7
8
namespace Users;

public class UnknownUserException : Exception
{
    public UnknownUserException(string message) : base(message)
    {
    }
}
  • Line 3: It derives from the [Exception] class;
  • line 5: it has only one constructor that takes an error message as a parameter. This parameter is passed to the parent class, which has the same constructor.

We now derive the child class [Administrator] from the class [User]:

namespace Users;

public class Administrator : User
{
    // constructor
    public Administrator(string login, string password)
        : base(login, password)
    {
    }

    // identification
    protected override string? Identify()
    {
        // LDAP identification
        // ...
        return "admin";
    }
}
  • lines 6–7: the constructor simply passes the parameters it receives to its parent class;
  • Lines 12–16: The [Identify] method of the [Administrator] class. Suppose an administrator is identified by a directory LDAP. This method overrides the abstract method [Identify] of its parent class: the keyword [override] is required (an abstract method is implicitly virtual). The 2008 course incorrectly stated the opposite. It retains the access specifier [protected] of the method it overrides.

We now derive the child class [Observer] from the class [User]:

namespace Users;

public class Observer : User
{
    // constructor
    public Observer(string login, string password)
        : base(login, password)
    {
    }

    // identification
    protected override string? Identify()
    {
        // identification through a database
        // ...
        return "observer";
    }
}
  • Lines 12–16: the [Identify] method of the [Observer] class. We assume that an observer is identified by verifying their identification data in a database.

Ultimately, the objects [Administrator] and [Observer] are initialized by the same constructor, that of the parent class [User]. This constructor uses the method [Identify] provided by these classes.

A third class, [Unknown], also derives from the [User] class:

namespace Users;

public class Unknown : User
{
    // constructor
    public Unknown(string login, string password)
        : base(login, password)
    {
    }

    // identification
    protected override string? Identify()
    {
        // user not known
        // ...
        return null;
    }
}
  • Line 16: The [Identify] method returns [null] to indicate that the user was not recognized.

A test program for [Program.cs] might look like this:

using Users;

Console.WriteLine(new Observer("observer", "pwd1"));
Console.WriteLine(new Administrator("admin", "pwd2"));
try
{
    Console.WriteLine(new Unknown("xx", "yy"));
}
catch (UnknownUserException e)
{
    Console.WriteLine("Unknown user: " + e.Message);
}
// User u = new User("x", "y");   // rejected: the User class is abstract

Note that in lines 3 and 4, the method [User.ToString()] is called by the method [WriteLine]. On line 7, the constructor throws a [UnknownUserException] exception, which is caught on line 9: the object is not created, and nothing is displayed by [WriteLine]. Line 13, if uncommented, is rejected by the compiler: you cannot create an object of an abstract class.

Results:

1
2
3
4
> dotnet run
User[observer,pwd1,observer]
User[admin,pwd2,admin]
Unknown user: [xx,yy]

An abstract class is similar to an interface: both declare members that other classes must implement. However, an abstract class can have fields, constructors, and code common to its derived classes; conversely, a class can derive from only one abstract class, whereas it can implement multiple interfaces. The two are often combined: an interface defines the contract, and an abstract class that implements it factors out the code common to multiple implementations. This is what the example application in this chapter will do.

4.8. Generic Classes, Interfaces, and Methods

4.8.1. Generic Classes and Methods

Suppose we want to write a method that swaps two integers. This method could look like this:

1
2
3
4
5
6
7
public static void Swap1(ref int value1, ref int value2)
{
    // we swap the values of value1 and value2
    int temp = value2;
    value2 = value1;
    value1 = temp;
}

Now, if we wanted to swap two references to [Person] objects, we would write:

1
2
3
4
5
6
7
public static void Swap2(ref Person value1, ref Person value2)
{
    // we swap the references value1 and value2
    Person temp = value2;
    value2 = value1;
    value1 = temp;
}

What distinguishes the two methods is the type T of the parameters: [int] in [Swap1], and [Person] in [Swap2]. Generic classes, interfaces, and methods address the need for code that differs only in the type of certain data. They were introduced with C# 2: prior to that, the type [object] was used, which accepts any type, at the cost of type conversions at runtime and errors detected only at runtime. This is what the [ArrayList] class, mentioned in the section on operators, did.

The program [classes-17.cs] features a generic class, a generic method, and a second, more realistic generic class:

// generic classes and methods
// --- a generic class Generic1<T>
// int
int i1 = 1, i2 = 2;
Generic1<int>.Swap(ref i1, ref i2);
Console.WriteLine($"i1={i1},i2={i2}");
// string
string s1 = "s1", s2 = "s2";
Generic1<string>.Swap(ref s1, ref s2);
Console.WriteLine($"s1={s1},s2={s2}");
// Person
Person p1 = new Person("jean", "clu", 20), p2 = new Person("pauline", "dard", 55);
Generic1<Person>.Swap(ref p1, ref p2);
Console.WriteLine($"p1={p1},p2={p2}");

// --- a generic method Generic2.Swap<T>
Generic2.Swap<int>(ref i1, ref i2);
Console.WriteLine($"i1={i1},i2={i2}");
// the compiler infers T from the type of the actual parameters
Generic2.Swap(ref s1, ref s2);
Console.WriteLine($"s1={s1},s2={s2}");
Generic2.Swap(ref p1, ref p2);
Console.WriteLine($"p1={p1},p2={p2}");

// --- a generic class with a state: a stack of elements of type T
MyStack<Person> stack = new MyStack<Person>();
stack.Push(p1);
stack.Push(p2);
Console.WriteLine($"The stack contains {stack.Count} persons");
Person top = stack.Pop();
Console.WriteLine($"Popped person: {top}, {stack.Count} left");
// stack.Push("x");   // rejected: the stack only accepts Person objects

// a generic class
class Generic1<T>
{
    public static void Swap(ref T value1, ref T value2)
    {
        // we swap the values of value1 and value2
        T temp = value2;
        value2 = value1;
        value1 = temp;
    }
}

// a non-generic class with a generic method
class Generic2
{
    public static void Swap<T>(ref T value1, ref T value2)
    {
        T temp = value2;
        value2 = value1;
        value1 = temp;
    }
}

// a stack of elements of type T
class MyStack<T>
{
    // the elements of the stack
    private readonly List<T> elements = new List<T>();

    // number of elements
    public int Count => elements.Count;

    // adds an element to the top of the stack
    public void Push(T element)
    {
        elements.Add(element);
    }

    // removes the element at the top of the stack
    public T Pop()
    {
        if (elements.Count == 0)
        {
            throw new InvalidOperationException("empty stack");
        }
        T element = elements[^1];
        elements.RemoveAt(elements.Count - 1);
        return element;
    }
}

// a person
class Person
{
    private readonly string firstName;
    private readonly string lastName;
    private readonly int age;

    public Person(string firstName, string lastName, int age)
    {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public override string ToString() => $"[{firstName}, {lastName}, {age}]";
}
  • lines 35–42: The class *[Generic1] is parameterized by a type denoted as *[T]. You can name it whatever you want. This type, [T], is then used within the class just like any other type: for the two parameters to be swapped and for the temporary variable [temp]. The class [Generic1] is said to be a generic class;
  • Line 5: When using a generic class parameterized by the types T1, T2, …, these types must be “instantiated.” Here, we use the static method [Swap] of type [Generic1<int>] to indicate that the references passed to the method [Swap] are of type [int]. Lines 9 and 13: the same applies to the types [string] and [Person];
  • Lines 47–49: The class [Generic2] is not generic, but its static method [Swap] is;
  • line 17: the method [Swap] is called, specifying the type of the parameters between [<>]. In fact, the compiler is able to deduce, based on the type of the actual parameters, which variant of the method [Swap] to use. Therefore, the code on lines 20–22 is valid;
  • lines 58–81: a generic class with a state: a stack of elements of type [T]. Elements are pushed onto the top of the stack and popped from the top. The elements are stored in a generic list of type [List<T>] (line 61), which is itself parameterized by the stack’s type, [T]. The expression [elements[^1]] (line 79) refers to the last element of the list;
  • line 26: a stack of people. The compiler guarantees that it will contain only people: the statement on line 32 would be rejected. The method [Pop] returns an object of type [Person], without conversion.

Results:

1
2
3
4
5
6
7
8
9
> dotnet run classes-17.cs
i1=2,i2=1
s1=s2,s2=s1
p1=[pauline, dard, 55],p2=[jean, clu, 20]
i1=1,i2=2
s1=s1,s2=s2
p1=[jean, clu, 20],p2=[pauline, dard, 55]
The stack contains 2 persons
Popped person: [pauline, dard, 55], 1 left

Note that, to swap two variables, C# 7 now offers a more concise syntax based on tuples: [(a, b) = (b, a);].

4.8.2. Constraints on Generic Parameters

You can place constraints on generic parameters using the keyword [where]. The main ones are as follows:

Constraint
Meaning
[where T : struct]
T must be a value type (structure), excluding nullable types such as [int?]
[where T : class]
T must be a reference type (class, interface, array, delegate)
[where T : notnull]
T must be a non-nullable type (value or reference)
[where T : new()]
T must have a public constructor with no parameters. This constraint comes last
[where T : BaseClass]
T must be the specified class or a derived class
[where T : IInterface]
T must implement the specified interface. Multiple interfaces may be specified
[where T : U]
T must be the type U or a type derived from U, where U is another generic parameter
[where T : unmanaged]
T must be a value type that contains no references

The program [classes-18.cs] uses two of them:

// constraints on generic parameters
// --- constraint where T : class
string s1 = "s1", s2 = "s2";
Generic3.Swap(ref s1, ref s2);
Console.WriteLine($"s1={s1},s2={s2}");
int i1 = 1, i2 = 2;
// Generic3.Swap(ref i1, ref i2);   // error CS0452: int is not a reference type
Console.WriteLine($"i1={i1},i2={i2}");

// --- constraint where T : IValue
// swapping instances of type RandomValue
for (int i = 0; i < 5; i++)
{
    RandomValue c1 = new RandomValue(), c2 = new RandomValue();
    Console.Write($"Before swap --> c1={c1},c2={c2}");
    Generic4.Order(ref c1, ref c2);
    Console.WriteLine($"   After swap --> c1={c1},c2={c2}");
}
// swapping instances of type StringValue
StringValue c3 = new StringValue("xxxxxxxxxxxxxx"), c4 = new StringValue("xx");
Console.WriteLine($"Before swap --> c3={c3},c4={c4}");
Generic4.Order(ref c3, ref c4);
Console.WriteLine($"After swap --> c3={c3},c4={c4}");

// T must be a reference type
class Generic3
{
    public static void Swap<T>(ref T value1, ref T value2) where T : class
    {
        T temp = value2;
        value2 = value1;
        value1 = temp;
    }
}

// the type T must implement IValue
class Generic4
{
    public static void Order<T>(ref T element1, ref T element2) where T : IValue
    {
        // if the 1st element is greater than the 2nd, we swap the elements
        if (element1.Value() > element2.Value())
        {
            T temp = element2;
            element2 = element1;
            element1 = temp;
        }
    }
}

// an object having an integer value
interface IValue
{
    int Value();
}

// an object having a random value
class RandomValue : IValue
{
    // random number generator shared by all the instances
    // (initialized with a fixed seed so that the results are reproducible)
    private static readonly Random generator = new Random(2026);
    // value of the object
    private readonly int value = generator.Next(100);

    public int Value() => value;

    public override string ToString() => value.ToString();
}

// an object whose value is the length of a string
class StringValue : IValue
{
    private readonly string s;

    public StringValue(string s)
    {
        this.s = s;
    }

    public int Value() => s.Length;

    public override string ToString() => s;
}
  • lines 26–28: the generic method [Swap] requires that the type [T] be a reference type (constraint [where T : class]);
  • line 7: if this line is uncommented, the compiler reports an error because the type [int] is not a class or an interface—it is a structure.

Here is the error message received when compiling the program after line 7 is uncommented:

> dotnet build classes-18.cs
classes-18.cs(7,10): error CS0452: The type 'int' must be a reference type in order to use it as parameter 'T' in the generic type or method 'Generic3.Swap<T>(ref T, ref T)'

The compiler message is displayed in the system language—in this case, English (on a computer configured for French, it would be in French). It lists the filename, the line and column numbers of the error, and the error code ([CS0452]), which can be looked up in the documentation.

Let’s return to the program [classes-18.cs]:

  • Lines 37–39: The type [T] must implement the interface [IValue] (lines 52–54). This interface has a method named [Value], used on line 42, which returns the value of the object of type [T]. Without this constraint, the compiler would reject the call to [element1.Value()]: any object of type [T] does not have a method named [Value]. The two references [element1] and [element2] are swapped only if the value of [element1] is greater than the value of [element2]: the method [Order] sorts the two elements in ascending order by value;
  • lines 58–68: the class [RandomValue] implements the interface [IValue]. The value of an instance is a random number between 0 and 99 (line 64), provided by a random number generator in the [Random] class shared by all instances (line 62). This generator is initialized with a fixed “seed”: it therefore always produces the same sequence of numbers, making the results reproducible. The 2008 course created a generator for each object, initialized with the current time, and had to wait one millisecond between creations to obtain different numbers. When reproducibility is not required, the shared generator [Random.Shared] can be used instead;
  • lines 72–83: the [StringValue] class also implements the [IValue] interface: the value of an instance is the length of the string passed to the constructor;
  • lines 12–17: Instances of type [RandomValue] are sorted;
  • lines 20–23: instances of type [StringValue] are sorted.

Results:

> dotnet run classes-18.cs
s1=s2,s2=s1
i1=1,i2=2
Before swap --> c1=15,c2=28   After swap --> c1=15,c2=28
Before swap --> c1=91,c2=99   After swap --> c1=91,c2=99
Before swap --> c1=91,c2=7   After swap --> c1=7,c2=91
Before swap --> c1=66,c2=85   After swap --> c1=66,c2=85
Before swap --> c1=62,c2=36   After swap --> c1=36,c2=62
Before swap --> c3=xxxxxxxxxxxxxx,c4=xx
After swap --> c3=xx,c4=xxxxxxxxxxxxxx

4.8.3. A generic interface: IComparer<T>

To illustrate the concept of a generic interface, we will sort an array of people first by their names, then by their ages. The method that allows us to sort an array is the static method [Sort] of the class [Array]. It has different signatures (it is overloaded). We will use the following signature:

public static void Sort<T>(T[] array, IComparer<T>? comparer)

[Sort] is a generic method where [T] denotes any type. The method takes two parameters:

  • [T[] array]: the array of elements of type [T] to be sorted;
  • [IComparer<T>? comparer]: a reference to an object that implements the [IComparer<T>] interface.

[IComparer<T>] is a generic interface defined as follows (simplified definition):

1
2
3
4
public interface IComparer<in T>
{
    int Compare(T? x, T? y);
}

The [IComparer<T>] interface has only one method. The [Compare] method:

  • takes two parameters, [x] and [y], of type [T]. The type [T?] indicates that their values can be [null] (the keyword [in] is a technical detail that we will not explain here);
  • returns a positive number if x > y, 0 if x == y, and a negative number if x < y. It is up to the developer to define the meaning of the operators <, ==, and >. For example, if [p1] and [p2] are two [Person] objects, we can say that p1 > p2 if the name of [p1] comes after the name of [p2] in alphabetical order. This will result in an ascending sort based on the people’s names. If we want to sort by age, we’ll say that p1 > p2 if the age of [p1] is greater than the age of [p2];
  • to sort in descending order, simply reverse the sign of the results.

We now know enough to sort an array of people. The program [classes-19.cs] is as follows:

// the generic interface IComparer<T>: sorting an array of persons
Person[] persons = [new Person("claude", "pollon", 25), new Person("valentine", "germain", 35), new Person("paul", "germain", 32)];
// display
Display("Array to sort", persons);
// sort by last name and first name
Array.Sort(persons, new CompareNames());
Display("Array after sorting by last name and first name", persons);
// sort by age
Array.Sort(persons, new CompareAges());
Display("Array after sorting by age", persons);

// display of an array of persons
static void Display(string text, Person[] persons)
{
    Console.WriteLine(text.PadRight(50, '-'));
    foreach (Person p in persons)
    {
        Console.WriteLine(p);
    }
}

// a person
class Person
{
    public Person(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
    }

    public string FirstName { get; }
    public string LastName { get; }
    public int Age { get; }

    public override string ToString() => $"[{FirstName}, {LastName}, {Age}]";
}

// comparison of the last and first names of two persons
class CompareNames : IComparer<Person>
{
    public int Compare(Person? p1, Person? p2)
    {
        // we rule out the case of null references
        ArgumentNullException.ThrowIfNull(p1);
        ArgumentNullException.ThrowIfNull(p2);
        // we compare the last names
        int i = string.Compare(p1.LastName, p2.LastName, StringComparison.CurrentCulture);
        if (i != 0)
        {
            return i;
        }
        // equal last names: we compare the first names
        return string.Compare(p1.FirstName, p2.FirstName, StringComparison.CurrentCulture);
    }
}

// comparison of the ages of two persons
class CompareAges : IComparer<Person>
{
    public int Compare(Person? p1, Person? p2)
    {
        ArgumentNullException.ThrowIfNull(p1);
        ArgumentNullException.ThrowIfNull(p2);
        // we compare the ages
        return p1.Age.CompareTo(p2.Age);
    }
}
  • Line 2: the array of people;
  • line 6: sorting the table of people by last name and first name. The second parameter of the generic method [Sort] is an instance of the class [CompareNames], which implements the generic interface [IComparer<Person>];
  • lines 40–54: the class [CompareNames]. Its method [Compare] implements the method [int Compare(T? x, T? y)] of the interface [IComparer<T>], where [T] = [Person]:
    • lines 45–46: Since the parameters are declared as type [Person?], the compiler requires that a null reference be handled before using [p1] and [p2]. The static method [ArgumentNullException.ThrowIfNull] throws an exception if its parameter is [null]; the compiler knows that after it is called, the parameter is not null;
    • line 48: the method [string.Compare] compares two strings in alphabetical order according to the current culture, in this case French;
  • line 9: sorting the array of people by age, using an instance of the [CompareAges] class from lines 59–66. The [CompareTo] method for numeric types returns a negative, zero, or positive number depending on whether the number is less than, equal to, or greater than its parameter: this is exactly what the [Compare] method expects.

Results:

> dotnet run classes-19.cs
Array to sort-------------------------------------
[claude, pollon, 25]
[valentine, germain, 35]
[paul, germain, 32]
Array after sorting by last name and first name---
[paul, germain, 32]
[valentine, germain, 35]
[claude, pollon, 25]
Array after sorting by age------------------------
[claude, pollon, 25]
[paul, germain, 32]
[valentine, germain, 35]

We will see in Chapter 7, which covers delegates, that we can also pass a simple comparison function to [Array.Sort] without writing a class: [Array.Sort(persons, (p1, p2) => p1.Age.CompareTo(p2.Age));].

4.8.4. Abstract Static Members and Generic Mathematics

Can we write a generic method that sums the elements of an array of numbers, regardless of their type ([int], [double], [decimal]…)? We would need to be able to write [sum + value] and have the zero element of type [T]. However, the operator [+] is a static method, and a standard interface constraint only allows the use of instance methods. Since C# 11, an interface can declare abstract static members ([static abstract]), which every type that implements it must provide. .NET 7 has derived “generic mathematics” from this: All numeric types implement the [INumber<T>] interface in the [System.Numerics] namespace, which declares the type’s zero ([T.Zero]), arithmetic operators, and so on. The program [classes-20.cs] demonstrates this:

using System.Numerics;

// static abstract interface members (C# 11) and generic math
Console.WriteLine($"Sum of integers: {Sum([1, 2, 3])}");
Console.WriteLine($"Sum of reals: {Sum([1.5, 2.25])}");
Console.WriteLine($"Sum of decimals: {Sum([10.5m, 0.25m])}");
// an interface of our own
Describe<Cat>();
Describe<Dog>();

// sum of the elements of an array of numbers of type T
// INumber<T> declares static abstract members: T.Zero, the + operator, ...
static T Sum<T>(T[] values) where T : INumber<T>
{
    T sum = T.Zero;
    foreach (T value in values)
    {
        sum += value;
    }
    return sum;
}

// the method uses a static member of the type T
static void Describe<T>() where T : IAnimal
{
    Console.WriteLine($"{typeof(T).Name} : {T.Sound}");
}

interface IAnimal
{
    // static member that each class must implement
    static abstract string Sound { get; }
}

class Cat : IAnimal
{
    public static string Sound => "meow";
}

class Dog : IAnimal
{
    public static string Sound => "woof";
}
  • lines 13–20: The generic method *[Sum] requires that *[T] implement *[INumber<T>]. It can then use the static member [T.Zero] (line 15) and the operator [+] of type [T]* (line 18);
  • lines 4–6: the same method adds together [int], [double], and [decimal]. Collection expressions create arrays of the expected type;
  • lines 29–32: a custom interface that declares an abstract static property [Sound];
  • lines 35–42: two classes implement the static property;
  • Lines 24–26: The generic method [Describe] uses the static member [T.Sound] of type [T], without requiring an instance of that type. [typeof(T)] returns the description of the type [T], whose property [Name] is the name.

Results:

1
2
3
4
5
6
> dotnet run classes-20.cs
Sum of integers: 6
Sum of reals: 3,75
Sum of decimals: 10,75
Cat : meow
Dog : woof

4.9. Namespaces

To write a line on the screen, we use the [Console.WriteLine(...)] statement. If we look at the definition of the class [Console] in the documentation for .NET, we find that it is part of the namespace [System]. This means that the class [Console] should be referred to as [System.Console], and we should actually write [System.Console.WriteLine(...)]. We avoid this by using a [using] clause:

1
2
3
using System;
...
Console.WriteLine(...);

This means that the [System] namespace is imported along with the [using] clause. When the compiler encounters a class name (in this case, [Console]), it searches for it in the various namespaces imported by the [using] clauses. Here, it finds the class [Console] in the namespace [System]. In a recent .NET project, we don’t even need to write [using System;]: this namespace is included in the project’s implicit **using** statements, as we saw at the beginning of the chapter.

The documentation provides a second piece of information about the [Console] class: the assembly that contains it, the [System.Console.dll] file. An assembly is the result of compiling a project: a [.dll] file (library) or an executable. To use the classes in an assembly, the compiler must be aware of it. The 2008 course demonstrated how to pass these references to the [csc] compiler via the command line ([csc /r:fic1.dll /r:fic2.dll prog.cs]). Today, we no longer call the compiler directly: the assemblies of the .NET framework are referenced automatically, and the [.csproj] file references the other libraries, either as packages ([PackageReference]), or as projects ([ProjectReference]), as we will see in Chapter 6, which is devoted to layered architectures.

When creating a class, you can create it within a namespace. The purpose of these namespaces is to avoid name conflicts between classes, for example, when they are distributed. Consider two companies, E1 and E2, distributing packaged classes in the assemblies [e1.dll] and [e2.dll], respectively. Suppose a client C purchases these two sets of classes, in which both companies have defined a class named [Person]. If the client’s program uses the class [Person], the compiler will not know whether to use the class [Person] from [e1.dll] or the one from [e2.dll]. It will report an error. If the company E1 creates its classes in a namespace named [E1] and the company E2 creates its classes in a namespace named [E2], the two classes named [Person] will then be named [E1.Person] and [E2.Person]. The client must use either [E1.Person] or [E2.Person] (or an alias) in its classes, but not [Person]. The namespace resolves this ambiguity.

To create a class in a namespace, the 2008 course stated:

1
2
3
4
namespace NamespaceName
{
    // class definition
}

This format remains valid (it allows you to include multiple namespaces in a single file), but since C# 10, the file-scope namespace—[namespace NamespaceName;]—placed at the beginning of the file is preferred, as it saves one level of indentation. This is the format we have been using since the beginning of this chapter.

The [Namespaces] project in the solution illustrates these concepts. It contains two classes, [Person], in two different namespaces. The first, [E1/Person.cs]:

// the classes of this file belong to the E1 namespace
namespace E1;

public class Person
{
    private readonly string firstName;
    private readonly string lastName;

    public Person(string firstName, string lastName)
    {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public override string ToString() => $"E1.Person[{firstName}, {lastName}]";
}

The second, *[E2/Person.cs]*:

// the classes of this file belong to the E2 namespace
namespace E2;

public class Person
{
    public Person(string name)
    {
        Name = name;
    }

    public string Name { get; }

    public override string ToString() => $"E2.Person[{Name}]";
}

Note that a source file’s folder does not determine its namespace; only the declaration *[namespace]* matters. However, it is a useful convention to organize files from a single namespace into a folder with the same name.

The file [GlobalUsings.cs] contains the following clauses [global using] (C# 10):

1
2
3
// imports valid for all the source files of the project
global using E1;
global using System.Text;

A [global using] directive applies to all source files in the project. This is how implicit using statements are implemented: during compilation, the SDK file generates a [obj/Debug/net10.0/Namespaces.GlobalUsings.g.cs] file that contains the lines [global using System;], [global using System.Collections.Generic;], [global using System.IO;], [global using System.Linq;], [global using System.Net.Http;], [global using System.Threading;], and [global using System.Threading.Tasks;]. You can also add a global "using" statement in the file [.csproj]: [<Using Include="E1" />].

The main program, [Program.cs], is as follows:

// namespace aliases and type aliases
using PersonE2 = E2.Person;
using Point = (int X, int Y);

// Person denotes E1.Person: the E1 namespace is imported in GlobalUsings.cs
Person p1 = new Person("Jean", "Dupont");
// PersonE2 is an alias of E2.Person
PersonE2 p2 = new PersonE2("Durand");
// the full name of a class removes any ambiguity
E2.Person p3 = new E2.Person("Martin");
Console.WriteLine($"{p1} {p2} {p3}");
// full name of the types and assembly that contains them
Console.WriteLine($"{typeof(Person).FullName}, {typeof(PersonE2).FullName}");
Console.WriteLine($"{typeof(Console).FullName} is in the assembly {typeof(Console).Assembly.GetName().Name}");
// StringBuilder is known thanks to global using System.Text
StringBuilder sb = new StringBuilder("abc");
Console.WriteLine(sb.Append("def"));
// Point is an alias of the tuple type (int X, int Y)
Point origin = (0, 0);
Console.WriteLine($"origin=({origin.X},{origin.Y})");
  • line 2: an alias: in this file, [PersonE2] refers to the class [E2.Person];
  • line 3: Starting with C# 12, an alias can refer to any type; here, it refers to a tuple with two fields named [X] and [Y];
  • line 6: [Person] refers to the class [E1.Person], thanks to the clause [global using E1;]. If the namespace [E2] were also imported, the name [Person] would be ambiguous and the compiler would reject it;
  • line 8: use of the alias;
  • line 10: the fully qualified class name resolves any ambiguity;
  • line 13: the property [FullName] of a type returns its full name, prefixed by its namespace;
  • line 14: the property [Assembly] of a type refers to the assembly that contains it;
  • line 16: the class [System.Text.StringBuilder] is defined via the clause [global using System.Text;];
  • line 19: use of the alias for the tuple type.

Results:

1
2
3
4
5
6
> dotnet run
E1.Person[Jean, Dupont] E2.Person[Durand] E2.Person[Martin]
E1.Person, E2.Person
System.Console is in the assembly System.Console
abcdef
origin=(0,0)

4.10. Records

Many classes serve only to carry data: a person, a grade, a tax bracket… For these, we always write the same code: a constructor that copies its parameters into properties, a [ToString] method that displays these properties, and often comparison methods. C# 9 introduced records: the compiler generates this code from a single-line declaration. We’ve already encountered the structure version ([record struct]); here’s the class version ([classes-21.cs]):

// records (C# 9)
Person r1 = new Person("Jean", "Dupont", 30);
Person r2 = new Person("Jean", "Dupont", 30);
// ToString generated by the compiler
Console.WriteLine(r1);
// equality by value: r1 and r2 are two different but equal objects
Console.WriteLine($"r1 == r2 : {r1 == r2}, same object: {ReferenceEquals(r1, r2)}");
// the properties are read-only (init)
// r1.Age = 31;   // rejected
// the with expression creates a modified copy
Person r3 = r1 with { Age = 31 };
Console.WriteLine(r3);
Console.WriteLine($"r1 == r3 : {r1 == r3}");
// deconstruction into variables
var (firstName, lastName, age) = r1;
Console.WriteLine($"first name={firstName}, last name={lastName}, age={age}");

// the same thing with a class
PersonC c1 = new PersonC("Jean", "Dupont", 30);
PersonC c2 = new PersonC("Jean", "Dupont", 30);
Console.WriteLine(c1);
Console.WriteLine($"c1 == c2 : {c1 == c2}");

// a derived record
Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
Console.WriteLine(e);
Console.WriteLine(e.Identity);
// equality takes the type into account: a Person is not equal to a Teacher
Person pe = new Person("Lucile", "Dumas", 56);
Console.WriteLine($"pe == e : {pe == e}");

// a positional record: FirstName, LastName, Age are public properties { get; init; }
record Person(string FirstName, string LastName, int Age)
{
    // members can be added
    public virtual string Identity => $"[{FirstName}, {LastName}, {Age}]";
}

// a record can derive from another record
record Teacher(string FirstName, string LastName, int Age, int Section) : Person(FirstName, LastName, Age)
{
    public override string Identity => $"Teacher[{base.Identity},{Section}]";
}

// an equivalent class... or almost
class PersonC
{
    public PersonC(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
    }

    public string FirstName { get; init; }
    public string LastName { get; init; }
    public int Age { get; init; }
}
  • Lines 33–36: a positional record. The declaration [record Person(string FirstName, string LastName, int Age)] generates:
    • a constructor with three parameters;
    • three read-only public properties: [FirstName], [LastName], and [Age] ([{ get; init; }]);
    • a method [ToString] that displays the type name and its public properties (line 5);
    • the methods [Equals] and [GetHashCode] and the operators [==] and [!=], which compare property values rather than references;
    • a method [Deconstruct] that breaks the object down into variables (line 15);
    • the expression [with];
    • a record can also, like a class, have additional members; here, the property [Identity];
  • Line 7: [r1] and [r2] are two distinct objects ([ReferenceEquals] returns [false]) but they are equal in the sense of [] because their properties have the same values. For an ordinary class, [] compares the references (line 22);
  • line 11: Since a record is, in principle, immutable, it is not modified (line 9): the expression [with] creates a copy of [r1] in which certain properties have been changed;
  • lines 40–42: A record can be derived from another record (not from an ordinary class). The parameters [FirstName], [LastName], and [Age] are passed to the parent record’s constructor. The property [Identity], declared as [virtual] in [Person], is redefined;
  • line 30: record equality takes into account the actual type of the objects: a [Person] is never equal to a [Teacher], even if their common properties are equal;
  • Lines 46–57: A class that resembles the record [Person]… except that the constructor and properties had to be written, and equality by value, [ToString] and [with] are not provided.

Results:

> dotnet run classes-21.cs
Person { FirstName = Jean, LastName = Dupont, Age = 30, Identity = [Jean, Dupont, 30] }
r1 == r2 : True, same object: False
Person { FirstName = Jean, LastName = Dupont, Age = 31, Identity = [Jean, Dupont, 31] }
r1 == r3 : False
first name=Jean, last name=Dupont, age=30
PersonC
c1 == c2 : False
Teacher { FirstName = Lucile, LastName = Dumas, Age = 56, Identity = Teacher[[Lucile, Dumas, 56],61], Section = 61 }
Teacher[[Lucile, Dumas, 56],61]
pe == e : False

Note that the generated method [ToString] also displays the calculated property [Identity] (line 2), whereas the [ToString] method of the class [PersonC] simply displays the class name (line 7).

Let’s summarize the differences between a class and a record:

 
Class
Record
Equality: [==], [Equals]
same object (equal references)
same property values, same type
[ToString]
type name
type name and public properties
constructor and properties
to be written
Generated by the positional declaration, properties [init]
modified copy
to be written
expression [with]
inheritance
from a class
from a record

A record is used for data defined by its value (a person retrieved from a database, a message exchanged over a network, a tax bracket), and a class is used for an object with an identity and behavior (a computing service, a connection, an editable list). A record is written as [record] (or [record class], which is identical) for a reference type, and [record struct] for a value type.

4.11. Primary constructors

Positional records declare their constructor parameters immediately after their name. Since C# 12, ordinary classes and structures can do the same: this is the primary constructor. The program [classes-22.cs] illustrates this:

// primary constructors (C# 12)
Person p = new Person("Jean", "Dupont", 30);
Console.WriteLine(p.Identity);
p.GrowOlder();
Console.WriteLine(p.Identity);
// another constructor
Person unknown = new Person("Martin");
Console.WriteLine(unknown.Identity);
// a derived class
Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
Console.WriteLine(e.Identity);
// a structure
Point pt = new Point(3, 4);
Console.WriteLine($"pt=({pt.X},{pt.Y}), distance to the origin: {pt.Distance()}");

// a class with a primary constructor
class Person(string firstName, string lastName, int age)
{
    // the parameters firstName and lastName are used to initialize properties
    public string FirstName { get; } = firstName;
    public string LastName { get; } = lastName;
    // the age parameter is used by the methods: it is kept in a hidden field
    public int Age => age;

    // any other constructor must call the primary constructor
    public Person(string lastName) : this("?", lastName, 0)
    {
    }

    public void GrowOlder() => age++;

    public virtual string Identity => $"[{FirstName}, {LastName}, {age}]";
}

// a derived class: it passes three of its parameters to the primary constructor of the parent class
class Teacher(string firstName, string lastName, int age, int section) : Person(firstName, lastName, age)
{
    public int Section { get; } = section;

    public override string Identity => $"Teacher[{base.Identity},{Section}]";
}

// a structure with a primary constructor
readonly struct Point(double x, double y)
{
    public double X { get; } = x;
    public double Y { get; } = y;

    public double Distance() => Math.Sqrt(X * X + Y * Y);
}
  • line 17: the class [Person] has a primary constructor with three parameters. Unlike those of a record, these parameters do not become public properties: they are parameters, visible throughout the entire body of the class;
  • lines 20–21: the parameters [firstName] and [lastName] are used to initialize two read-only properties;
  • line 23: the parameter [age] is used by members of the class: the property [Age], the method [GrowOlder] (line 30), and the property [Identity]. The compiler then stores it in a hidden field, which can be modified ([age++]). It is recommended not to mix these two uses for the same parameter (initializing a property and using it directly), as this may result in two copies of the same data;
  • line 26: a class with a primary constructor may have other constructors, but these must call the primary constructor using [this(...)];
  • line 36: A derived class passes some of the parameters from its primary constructor to the constructor of its parent class: [: Person(firstName, lastName, age)]. This is equivalent to [: base(firstName, lastName, age)] in a traditional constructor;
  • line 44: A structure can also have a primary constructor.

Results:

1
2
3
4
5
6
> dotnet run classes-22.cs
[Jean, Dupont, 30]
[Jean, Dupont, 31]
[?, Martin, 0]
Teacher[[Lucile, Dumas, 56],61]
pt=(3,4), distance to the origin: 5

The primary constructor eliminates repetitive code from constructors that merely store their parameters. It is widely used for classes that receive services during construction, as we will see with dependency injection (Chapter 6).

4.12. Extension Methods and Members

How do you add a method to a class that cannot be modified, such as the class [string] derived from .NET? C# 3 introduced extension methods: static methods that are used as instance methods of the extended type. C# 14 extends this mechanism to properties and static members with extension blocks. The program [classes-23.cs] demonstrates both:

// extension methods (C# 3) and extension members (C# 14)
Person p = new Person("jean", "dupont", 30);
// classic extension methods: they are used like instance methods
Console.WriteLine(p.Initials());
Console.WriteLine("jean".FirstUpper());
// the previous call is translated by the compiler into the following static method call
Console.WriteLine(ClassicExtensions.FirstUpper("jean"));
// extension properties (C# 14)
Console.WriteLine($"{p.FullName} is an adult: {p.IsAdult}");
Console.WriteLine($"\"the weather is nice\" has {"the weather is nice".NbWords} words");
// static extension member (C# 14): it is used with the name of the type
Person unknown = Person.Unknown;
Console.WriteLine($"{unknown.FullName} is an adult: {unknown.IsAdult}");

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

// classic extension methods: static methods of a static class,
// whose first parameter is preceded by the this keyword
static class ClassicExtensions
{
    public static string Initials(this Person p)
    {
        return $"{char.ToUpper(p.FirstName[0])}.{char.ToUpper(p.LastName[0])}.";
    }

    public static string FirstUpper(this string s)
    {
        return s.Length == 0 ? s : char.ToUpper(s[0]) + s[1..];
    }
}

// extension blocks (C# 14)
static class Cs14Extensions
{
    // instance members added to the Person type: p denotes the extended object
    extension(Person p)
    {
        public bool IsAdult => p.Age >= 18;

        public string FullName => $"{p.FirstName.FirstUpper()} {p.LastName.ToUpper()}";
    }

    // static members added to the Person type
    extension(Person)
    {
        public static Person Unknown => new Person("?", "?", 0);
    }

    // a property added to the string type
    extension(string s)
    {
        public int NbWords => s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
    }
}
  • lines 20–29: Traditional extension methods are static methods of a static class. Their first parameter, preceded by the keyword [this], specifies the extended object:
    • [Initials(this Person p)] extends the type [Person]: it is called [p.Initials()] (line 4);
    • [FirstUpper(this string s)] extends the type [string]: it is called ["jean".FirstUpper()] (line 5). The expression [s[1..]] refers to the substring starting at character #1;
  • line 7: an extension method remains an ordinary static method: the compiler translates ["jean".FirstUpper()] into this call. The method has access only to the public members of the extended object;
  • lines 34–53: the C# 14 extension blocks, also declared within a static class:
    • lines 37–41: the block ***[extension(Person p)] adds the instance members it contains to the type *[Person]. *[p]* refers to the extended object. Properties can now be added ([IsAdult], [FullName]), which was not possible with traditional extension methods;
    • lines 45–47: the block [extension(Person)], with no parameter names, adds static members to the type [Person]: [Person.Unknown] (line 12) is written as if the property had been declared in the record [Person];
    • Lines 51–53: An extension property of type [string], used on line 10.

Results:

1
2
3
4
5
6
7
> dotnet run classes-23.cs
J.D.
Jean
Jean
Jean DUPONT is an adult: True
"the weather is nice" has 4 words
? ? is an adult: False

Extension methods are ubiquitous in .NET: the operators of LINQ ([Where], [Select], [OrderBy]…), discussed in the next chapter, are extension methods of the [IEnumerable<T>] interface. For an extension to be usable, the namespace of its static class must be imported.

4.13. Pattern Filtering on Objects

The expression [switch] and pattern filtering are not limited to simple values (numbers, strings); they also apply to objects. You can test an object’s type, check the values of its properties, and retrieve the object converted to the correct type—all in a single expression. The program [classes-24.cs] uses a hierarchy of three records:

// pattern matching on objects
object?[] objects =
[
    new Person("Jean", "Dupont", 30),
    new Person("Lise", "Morel", 12),
    new Teacher("Lucile", "Dumas", 56, 61),
    new Teacher("Marc", "Petit", 44, 27),
    new Student("Paul", "Martin", 19, "computer science"),
    "a string",
    42,
    null,
];

// 1 - the type pattern: is Type variable
foreach (object? o in objects)
{
    if (o is Person p)
    {
        // here p is a Person (or an object of a derived class)
        Console.WriteLine($"{p.FirstName} is of type {p.GetType().Name}");
    }
}

// 2 - the property pattern: { Property: pattern }
foreach (object? o in objects)
{
    if (o is Person { Age: >= 18 } adult)
    {
        Console.WriteLine($"{adult.FirstName} is an adult");
    }
}

// 3 - the switch expression on the type and the properties of the object
foreach (object? o in objects)
{
    Console.WriteLine(Describe(o));
}

static string Describe(object? o) => o switch
{
    Teacher { Section: 27 } e => $"{e.FirstName} teaches computer science",
    Teacher e => $"{e.FirstName} teaches in section {e.Section}",
    Student(var firstName, _, _, var major) => $"{firstName} studies {major}",
    Person { Age: < 18 } p => $"{p.FirstName} is a minor",
    Person p => $"{p.FirstName} is {p.Age} years old",
    string s => $"a string of {s.Length} characters",
    int n and > 0 => $"the positive integer {n}",
    null => "the null reference",
    _ => "an unknown object",
};

record Person(string FirstName, string LastName, int Age);
record Teacher(string FirstName, string LastName, int Age, int Section) : Person(FirstName, LastName, Age);
record Student(string FirstName, string LastName, int Age, string Major) : Person(FirstName, LastName, Age);
  • lines 52–54: a [Person] record and two derived records, [Teacher] and [Student];
  • lines 2–11: an array of objects of various types (the type [object?] also accepts [null]);
  • line 17: the pattern of type [o is Person p] is true if [o] references an object of type [Person] or a derived type; in this case, the variable [p], of type [Person], references this object. This avoids the sequence from the 2008 course: testing the type with [is], then converting with [(Person)o]. The pattern is false if [o] is equal to [null];
  • line 27: the property pattern [{ Age: >= 18 }] (C# 8) tests the value of a property of the object, in this case using a relational pattern [>= 18] (C# 9). Multiple properties can be tested by separating them with commas;
  • lines 39–49: the expression [switch] tries the patterns in order and returns the value associated with the first one that matches:
    • line 41: a teacher from section 27;
    • line 42: any other teacher. The order is important: if placed before the previous one, this pattern would have made it inaccessible, which the compiler would have flagged as an error;
    • line 43: a positional pattern: it uses the method [Deconstruct] of the record [Student] to extract its properties in the order they were declared; [_] ignores a property;
    • line 44: a minor;
    • line 47: a positive integer, using the [and] combinator (C# 9); there are also [or] and [not];
    • line 48: the pattern [null];
    • line 49: the pattern [_] accepts everything. Without it, the compiler would report that the expression [switch] does not cover all cases.

Results:

> dotnet run classes-24.cs
Jean is of type Person
Lise is of type Person
Lucile is of type Teacher
Marc is of type Teacher
Paul is of type Student
Jean is an adult
Lucile is an adult
Marc is an adult
Paul is an adult
Jean is 30 years old
Lise is a minor
Lucile teaches in section 61
Marc teaches computer science
Paul studies computer science
a string of 8 characters
the positive integer 42
the null reference

Note that the type pattern also matches derived types: the type pattern loop (lines 2–6) captured the teachers and the student, who are people. Pattern matching is a useful alternative to cascading [if (o is ...)]; however, it should not replace polymorphism: when behavior depends on the object’s type and you have control over the class hierarchy, a virtual method redefined in each class remains the object-oriented solution.

4.14. Nullable reference types

Since the beginning of this chapter, our projects have had nullable reference types enabled ([<Nullable>enable</Nullable>] in [.csproj], an option enabled by default in single-file applications). The principle is as follows:

  • A variable, field, parameter, or reference-type result declared without a question mark—for example, of type [Person]—is never supposed to be equal to [null];
  • if it can be [null], it is declared with a question mark: [Person?];
  • the compiler analyzes the program flow and issues a warning whenever a potentially null value is used without verification, or assigned to a non-nullable variable.

The language does not prohibit anything at runtime: these are compilation warnings, but a program that compiles without warnings is much less prone to the [NullReferenceException] exception, the most common runtime error. The 2008 course, which predates this feature (C# 8, 2019), obviously did not cover it. The program [classes-25.cs] demonstrates the operators associated with nullable types:

// nullable reference types applied to objects
Person[] persons = [new Person("Jean", "Dupont") { MiddleName = "Marie" }, new Person("Paul", "Martin")];
foreach (Person p in persons)
{
    // ?. : returns null if the object on the left is null, otherwise accesses the member
    int? length = p.MiddleName?.Length;
    // ?? : returns the right operand if the left one is null
    Console.WriteLine($"{p.FirstName} : middle name={p.MiddleName ?? "(none)"}, length={length?.ToString() ?? "null"}");
}

// a method that can return null: its return type is Person?
Person? found = Find(persons, "Durand");
Console.WriteLine(found?.FirstName ?? "Durand: person not found");
// after a test, the compiler knows that the reference is not null
found = Find(persons, "Martin");
if (found is not null)
{
    Console.WriteLine($"Martin : {found.FirstName}");
}
// ??= : assigns a value only if the variable (or the property) is null
persons[1].MiddleName ??= "Pierre";
persons[0].MiddleName ??= "Pierre";
Console.WriteLine($"{persons[0].MiddleName}, {persons[1].MiddleName}");
// C# 14: conditional assignment: it is only done if the object is not null
Find(persons, "Dupont")?.MiddleName = "Louise";
Find(persons, "Durand")?.MiddleName = "Louise";
Console.WriteLine(persons[0].MiddleName);
// ! : we assert to the compiler that the reference is not null
Person martin = Find(persons, "Martin")!;
Console.WriteLine(martin.FirstName);
// the following lines cause compiler warnings
// Console.WriteLine(new Person("Lise", "Morel").MiddleName.Length);   // warning
// Person dupond = Find(persons, "Dupond");   // warning
// persons[0] = null;   // warning

// search for a person by last name
static Person? Find(Person[] persons, string lastName)
{
    foreach (Person p in persons)
    {
        if (p.LastName == lastName)
        {
            return p;
        }
    }
    return null;
}

class Person
{
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    // non-nullable properties: they must never be null
    public string FirstName { get; }
    public string LastName { get; }
    // nullable property: null means "no middle name"
    public string? MiddleName { get; set; }
}
  • lines 58–61: The class [Person] has two non-nullable properties, initialized by the constructor, and one nullable property, [MiddleName]: [null] means “no middle name”;
  • line 6: the [?.] operator (C# 6) returns [null] if the object on its left is [null], and accesses the member otherwise. The result is of type [int?];
  • line 8: the operator [??] returns its left operand if it is not [null], and its right operand otherwise;
  • lines 37–46: the function [Find] returns the person being searched for or [null]; its return type is [Person?];
  • line 13: the two operators are combined;
  • lines 16–18: After the [is not null] check, the compiler knows that [found] is not equal to [null]: [found.FirstName] can be written without a warning;
  • lines 21–22: the operator [??=] only assigns a value to the property if it is [null]: Jean’s middle name, “Marie,” is not replaced;
  • lines 25–26: C# 14 allows [?.] on the left side of an assignment: the assignment is performed only if the object is not null. Dupont exists and is given a new middle name; Durand does not exist, and the statement does nothing;
  • line 29: the [!] operator (a “null-forgiving” operator) tells the compiler that we know the value is not null: it does not issue a warning. It’s up to the developer to be sure of this: if the value is null at runtime, an error will occur later on. Use sparingly;
  • lines 32–34: three statements that trigger warnings.

Results:

1
2
3
4
5
6
7
8
> dotnet run classes-25.cs
Jean : middle name=Marie, length=5
Paul : middle name=(none), length=null
Durand: person not found
Martin : Paul
Marie, Pierre
Louise
Paul

Let’s uncomment the last three lines of the main program and compile:

1
2
3
4
> dotnet build classes-25.cs
classes-25.cs(32,19): warning CS8602: Dereference of a possibly null reference.
classes-25.cs(33,17): warning CS8600: Converting null literal or possible null value to non-nullable type.
classes-25.cs(34,14): warning CS8625: Cannot convert null literal to non-nullable reference type.
  • Warning [CS8602]: The property [MiddleName] of a newly created person may be [null]: You cannot use its property [Length] without verification;
  • Warning [CS8600]: The function [Find] may return [null], a value that is assigned to a non-nullable variable of type [Person];
  • Warning [CS8625]: The constant [null] is assigned to an element of type [Person], which is non-nullable.

The program would run despite these warnings, but the first line would throw a [NullReferenceException] exception. In this course, all programs compile without any warnings: we correct the code (by declaring the type as nullable, checking the value, or providing a default value using [??]) rather than ignoring the warning or suppressing it with [!].

4.15. Sample Application - V2

We’ll revisit the tax calculation we covered in the previous chapter (Version 1) and implement it using classes and interfaces. Let’s review the problem. We aim to write a program to calculate a taxpayer’s tax. We’ll consider the simplified case of a taxpayer who has only a single salary to report (using the 2004 tax schedule for 2003 income, kept as-is: this is an exercise):

  • we calculate the employee’s number of tax brackets: shares = nbChildren / 2 + 1 if they are unmarried, nbChildren / 2 + 2 if he is married, where nbChildren is the number of his children;
  • if he has at least three children, he receives an additional half-share;
  • his taxable income R is calculated as R = 0.72 × S, where S is his annual salary;
  • his family quotient is calculated as QF = R / shares;
  • his tax I is calculated using the following table:
threshold
coeffR
coeffN
4962
0
0
8382
0.068
291.09
14,753
0.191
1,322.92
23,888
0.283
2,668.39
38,868
0.374
4,846.98
47,932
0.426
6,883.66
0
0.481
9,505.54

Each row has three fields. To calculate tax I, find the first row where QF ≤ the limit. For example, if QF = 5,000, the row (8,382; 0.068; 291.09) will be found. Tax I is then equal to 0.068 × R - 291.09 × shares. If QF is such that the condition QF ≤ limit is never satisfied, then the coefficients of the last row are used, resulting in tax I = 0.481 × R - 9,505.54 × shares.

The application forms the [TaxV2] project of the solution:

1
2
3
4
5
6
7
TaxV2/
  TaxBracket.cs        a bracket of the tax scale
  ITax.cs              the tax calculation interface
  AbstractTax.cs       an abstract class implementing ITax
  HardwiredTax.cs      a derived class, with hard-coded data
  Program.cs           the program that dialogues with the user
  TaxV2.csproj

First, we define a type capable of encapsulating a row from the previous array [TaxBracket.cs]:

1
2
3
4
namespace TaxV2;

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

The 2008 course used a structure with three automatic read-write properties. We use an immutable record structure ([readonly record struct]):

  • a structure because a bracket is a small piece of data without an identity, defined solely by its value: an array of brackets directly contains the brackets, without a separate object in memory for each one. With three [decimal] (48 bytes), the structure exceeds the size of approximately 16 bytes usually recommended for a structure; this is not a problem here: there are few brackets, and they are copied only when they are read;
  • immutable ([readonly]) because there is no reason for a tax bracket to be modified once it has been created. The 2008 course, incidentally, modified the limit of the last tax bracket during the calculation—a side effect that we are eliminating;
  • a record ([record]) because the compiler generates the constructor, the properties, [ToString], and the equality operator from a single line.

Then we define an interface [ITax] capable of calculating the tax [ITax.cs]:

1
2
3
4
5
6
7
namespace TaxV2;

public interface ITax
{
    // tax calculation for a taxpayer
    int Calculate(bool married, int nbChildren, int salary);
}
  • Line 6: The method for calculating the tax based on three pieces of data: the taxpayer’s marital status, the number of children, and annual salary. It returns the tax rounded down to the nearest euro.

Next, we define an abstract class that implements this interface [AbstractTax.cs]:

namespace TaxV2;

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

    // tax calculation
    public int Calculate(bool married, int nbChildren, int salary)
    {
        // calculation of the number of shares
        decimal shares = (decimal)nbChildren / 2 + (married ? 2 : 1);
        if (nbChildren >= 3)
        {
            shares += 0.5m;
        }
        // calculation of the taxable income and of the family quotient
        decimal income = 0.72m * salary;
        decimal quotient = income / shares;
        // search for the tax bracket: the last one is used if no limit fits
        TaxBracket[] brackets = TaxBrackets;
        TaxBracket bracket = brackets[^1];
        for (int i = 0; i < brackets.Length - 1; i++)
        {
            if (quotient <= brackets[i].Limit)
            {
                bracket = brackets[i];
                break;
            }
        }
        // tax calculation
        return (int)(income * bracket.CoeffR - shares * bracket.CoeffN);
    }
}
  • Line 3: The class [AbstractTax] implements the interface [ITax];
  • line 7: the scale data, in the form of a protected abstract property. The class [AbstractTax] does not know where this data comes from (written in the code, read from a file, a database, etc.). It leaves this up to the derived classes. This is why it is declared abstract, which prevents it from being instantiated. The 2008 course used a protected field that derived classes had to initialize; the abstract property requires derived classes to provide it: the compiler verifies this;
  • lines 10–33: the implementation of the [Calculate] method of the [ITax] interface. Derived classes will not need to rewrite this method. The [AbstractTax] class thus serves as a factorization class for the derived classes: it contains what is common to all of them;
  • line 13: the number of shares. The conversion [(decimal)nbChildren] is essential: without it, [nbChildren / 2] would be an integer division (3 / 2 = 1). The expression [married ? 2 : 1] equals 2 for a married taxpayer, 1 otherwise;
  • Lines 19–20: taxable income and the family quotient. All calculations are performed using [decimal], a type adapted to the amounts: the suffix [m] of [0.72m] denotes a constant of this type;
  • Lines 22–29: Determining the tax bracket. By default, this is the last bracket ([brackets[^1]], line 23). The loop iterates through all brackets except the last one and stops ([break]) at the first bracket whose limit is greater than or equal to the family quotient;
  • line 33: tax calculation. The conversion [(int)] truncates the result [decimal] to its integer part.

A class that implements the [ITax] interface can be created by deriving it from the [AbstractTax] class. This is what we will now do with [HardwiredTax.cs]:

namespace TaxV2;

public class HardwiredTax : AbstractTax
{
    // tax calculation data (2004 tax scale on 2003 income), hard-coded
    private static readonly decimal[] limits = [4962m, 8382m, 14753m, 23888m, 38868m, 47932m, 0m];
    private static readonly decimal[] coeffR = [0m, 0.068m, 0.191m, 0.283m, 0.374m, 0.426m, 0.481m];
    private static readonly decimal[] coeffN = [0m, 291.09m, 1322.92m, 2668.39m, 4846.98m, 6883.66m, 9505.54m];

    // the tax brackets
    private readonly TaxBracket[] taxBrackets;

    // constructor
    public HardwiredTax()
    {
        // creation and filling of the array of tax brackets
        taxBrackets = new TaxBracket[limits.Length];
        for (int i = 0; i < taxBrackets.Length; i++)
        {
            taxBrackets[i] = new TaxBracket(limits[i], coeffR[i], coeffN[i]);
        }
    }

    // implementation of the abstract property of the parent class
    protected override TaxBracket[] TaxBrackets => taxBrackets;
}
  • Lines 6–8: The class [HardwiredTax] hard-codes the data needed to calculate the tax. These are class fields ([static]) that are read-only ([readonly]): they are shared by all instances and cannot be replaced by other arrays;
  • lines 14–20: the constructor uses this data to build the tax bracket table;
  • line 25: the parent class’s abstract property is implemented: it returns the tax bracket table.

The next chapter will add other classes derived from [AbstractTax], which will read the tax brackets from a text file or JSON: they will only need to provide the [TaxBrackets] property.

A test program might look like the following [Program.cs]:

using TaxV2;

// interactive tax calculation program
// the user types three data items on the keyboard: married nbChildren salary
// the program then displays the tax to pay

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

// creation of an ITax object
ITax tax = new HardwiredTax();

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

The program above allows the user to perform repeated tax calculation simulations.

  • lines 7–11: the help message is written as a raw string literal (C# 11), delimited by three quotation marks: the string can span multiple lines, and the common indentation of the lines, determined by the position of the closing quotation marks, is removed;
  • Line 15: Creation of a [tax] object that implements the [ITax] interface. This object is obtained by instantiating a type [HardwiredTax], a type that implements the [ITax] interface. Note that the variable [tax] was not assigned the type [HardwiredTax] but rather the type [ITax]. By writing this, we indicate that we are only interested in the [Calculate] method of the [tax] object and not in the rest. To use data from a file, simply change this single line;
  • lines 18–56: the loop for tax calculation simulations;
  • line 22: the three parameters required for the [Calculate] method are requested in a single line entered via the keyboard. [Console.ReadLine] returns [null] if standard input is closed: the operator [??] then replaces [null] with an empty string, which terminates the program;
  • line 29: the method [Split] splits the line into words separated by spaces, with the option [StringSplitOptions.RemoveEmptyEntries] removing empty words caused by consecutive spaces;
  • lines 44–50: the method [int.TryParse] verifies that the last two fields are integers; the declaration [out int nbChildren] creates the variable that receives the number. The 2008 course used [int.Parse] within a [try / catch] block;
  • line 56: call to the [Calculate] method of the [tax] object implementing the [ITax] interface.

Here is an example of the program in action:

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

The tax amounts are calculated correctly: 4,282 euros for a married taxpayer with two children and an annual salary of 60,000 euros (line 26), and 2,959 euros with three children (line 28). An unmarried taxpayer with no children pays 11,519 euros for the same salary (line 30).

Compared to Version 1, the calculation is now encapsulated in classes behind an interface: the dialog program only recognizes the [ITax] interface. Subsequent versions will build on this separation: Version 3 will change the data source by deriving new classes from [AbstractTax], and Version 4 will organize the application into layers.