Now that you have added fields to a class and can store data, you need to consider the validity of that data. As you saw in Listing 6.6, it is possible to instantiate an object using the new operator. The result, however, is the ability to create an employee with invalid data. Immediately following the assignment of employee, you have an Employee object whose name and salary are not initialized. In Listing 6.6, you assigned the uninitialized fields immediately following the instantiation of an employee—but if you failed to do the initialization, you would not receive a warning from the compiler. As a result, you could end up with an Employee object with an invalid name. (Technically, in C# 8.0, non-nullable reference types will trigger a warning suggesting that the data type be switched to nullable to avoid a default of null. Regardless, initialization is required to avoid instantiating objects whose fields contain invalid data.)
To correct this problem, you need to provide a means of specifying the required data when the object is created. You do this using a constructor, as demonstrated in Listing 6.26.
For the primary constructor, notice how the type declaration has a method signature following it. This provides variables (called positional parameters) that are scoped to the class and, as shown in Listing 6.26, can be assigned as property initializers. The primary constructor variables are available to any instance member of the class, thus allowing the assignment of firstName and lastName to FirstName and LastName properties respectively.
The constructor is the method that the runtime calls to initialize an instance of the object. In this case, the constructor takes the first name and the last name as parameters, allowing the programmer to specify these names when instantiating the Employee object. Listing 6.27 is an example of how to call a constructor.
Notice that the new operator returns the type of the object being instantiated. In addition, the initialization for the first and last names occurs via the property initializers that execute during construction. In this example, you don’t initialize Salary within the constructor, so the code assigning the salary still appears.
Internally, the interaction between the new operator and the constructor is as follows. The new operator retrieves “empty” memory from the memory manager and then calls the specified constructor, passing a reference to the empty memory to the constructor as the implicit this parameter. Next, the remainder of the constructor chain executes, passing around the reference between constructors. None of the constructors have a return type; behaviorally they all return void. When execution of the constructor chain is complete, the new operator returns the memory reference, now referring to the memory in its initialized form.
It is also possible to define constructors separately from the type declaration. As shown in Listing 6.28, to define a (non-primary) constructor, you create a method with no return type, whose method name is identical to the type name. Even though no return type or return statement was specified in the constructor’s declaration or implementation, invoking the constructor (via the new operator), still returns an instance of the type.
Developers should take care when using both assignment at declaration time and assignment within constructors. Assignments within the constructor will occur after any assignments are made when a property or field is declared (such as string Salary { get; set; } = "Not enough" in Listing 6.26). Therefore, assignment within a constructor will override any value assigned at declaration time. This subtlety can lead to a misinterpretation of the code by a casual reader who assumes the value after instantiation is the one assigned in the property or field declaration. Therefore, it is worth considering a coding style that does not mix both declaration assignment and constructor assignment for the same field or property.
When you add a constructor explicitly, you can no longer instantiate an Employee from within Main() without specifying the first and last names. The code shown in Listing 6.29, therefore, will not compile.
If a class has no explicitly defined constructor, the C# compiler adds one during compilation. This constructor takes no parameters so, by definition, it is the default constructor. As soon as you add an explicit constructor to a class, the C# compiler no longer provides a default constructor. Therefore, with Employee(string firstName, string lastName) defined, the default constructor, Employee(), is not added by the compiler. You could manually add such a constructor, but then you would again be allowing construction of an Employee without specifying the employee’s name.
It is not necessary to rely on the default constructor defined by the compiler. That is, programmers can define a default constructor explicitly—perhaps one that initializes some fields to particular values. Defining the default constructor simply involves declaring a constructor that takes no parameters.
A copy constructor is a constructor that takes a single parameter of the containing type. For example:
public Employee(Employee original)
{
// Copy properties between employees here.
}
Constructors like this are useful for cloning an instance of an object into a new duplicate instance.
To initialize an object’s accessible fields and properties, you can use the concept of an object initializer—a set of member initializers enclosed in curly braces following the constructor call to create the object. Each member initializer is the assignment of an accessible field or property name with a value (see Listing 6.30).
Notice that the same constructor rules apply even when using an object initializer. The order of member initializers in C# provides the sequence for property and field assignment in the statements following the constructor call within CIL.
In general, all properties should be initialized to reasonable default values by the time the constructor exits. Moreover, by using validation logic on the setter, it is possible to restrict the assignment of invalid data to a property. On occasion, the values on one or more properties may cause other properties on the same object to contain invalid values. When this occurs, exceptions from the invalid state should be postponed until the invalid interrelated property values become relevant.
Using a similar syntax to that of object initializers, collection initializers8 support a similar feature set as object initializers, only with collections. Specifically, a collection initializer allows the assignment of items within the collection at the time of the collection’s instantiation. Borrowing the same syntax used for arrays, the collection initializer initializes each item within the collection as part of collection creation. Initializing a list of Employee objects, for example, involves specifying each item within curly braces following the constructor call, as shown in Listing 6.31.
After the assignment of a new collection instance, the compiler-generated code instantiates each object in sequence and adds them to the collection via the Add() method.
Object initializers allow for specifying member values during object initialization. Any read-only properties cannot be set this way, however, because properties with only getters may only be set during object construction and object initializers runs after this. To address the problem, C# 9.0 added support for init only setters, which can be set from within object initializers but not afterward. Listing 6.32 demonstrates the use of init only setter for the Salary property.
Notice that while you can set the value of Salary from the object initializer, you can’t change the value once the initializer is complete. After that, the value is read-only.
Constructors define what happens during the instantiation process of a class. To define what happens when an object is destroyed, C# provides the finalizer construct. Unlike destructors in C++, finalizers do not run immediately after an object goes out of scope. Rather, the finalizer executes at some unspecified time after an object is determined to be “unreachable.” Specifically, the garbage collector identifies objects with finalizers during a garbage collection cycle, and instead of immediately de-allocating those objects, it adds them to a finalization queue. A separate thread runs through each object in the finalization queue and calls the object’s finalizer before removing it from the queue and making it available for the garbage collector again. Chapter 10 discusses this process, along with resource cleanup, in depth.
Constructors can be overloaded—you can have more than one constructor if the number or types of the parameters vary. For example, as Listing 6.33 shows, you could provide a constructor that has an employee ID with first and last names, or even just the employee ID.
This approach enables Program.Main() to instantiate an employee from the first and last names either by passing in the employee ID only or by passing both the names and the IDs. You would use the constructor with both the names and the IDs when creating a new employee in the system. You would use the constructor with only the ID to load the employee data from a file or a database.
As is the case with method overloading, multiple constructors are used to both support simple scenarios using a small number of parameters and complex scenarios with additional parameters. Consider using optional parameters in favor of overloading so that the default values for “defaulted” properties are visible in the API. For example, a constructor signature of Person(string firstName, string lastName, int? age = null) provides signature documentation that if the Age of a Person is not specified, it will default to null.
Notice also that it is possible to have expression bodied member implementations of constructors,9 as in
// FirstName&LastName set inside Id property setter.
#pragma warning disable CS8618
public Employee(int id) => Id = id;
In this case, we invoke the Id property to assign FirstName and LastName. Unfortunately, the compiler doesn’t detect the assignment and, starting with C# 8.0, issues a warning to consider marking those properties as nullable. Since we are, in fact, setting them, the warning is disabled.
Notice in Listing 6.33 that the initialization code for the Employee object is now duplicated in multiple places, so it also must be maintained in multiple places. The amount of code is small, but there are ways to eliminate the duplication by calling one constructor from another—a practice known as constructor chaining—using constructor initializers. Constructor initializers determine which constructor to call before executing the implementation of the current constructor (see Listing 6.34).
To call one constructor from another within the same class (for the same object instance), C# uses a colon followed by the this keyword, followed by the parameter list on the called constructor’s declaration. In this case, the constructor that takes all three parameters calls the constructor that takes two parameters. Often, this calling pattern is reversed—that is, the constructor with the fewest parameters calls the constructor with the most parameters, passing defaults for the parameters that are not known.
Notice that in the Employee(int id) constructor implementation in Listing 6.34, you cannot call this(firstName, lastName) because no such parameters exist on this constructor. To enable such a pattern, in which all initialization code happens through one method, you must create a separate method, as shown in Listing 6.35.
In this case, the method is called Initialize(), and it takes both the names and the employee IDs. Note that you can continue to call one constructor from another, as shown in Listing 6.34.
In the same way that setting the LastName and FirstName via the Id property wasn’t detected by the compiler, assignment via the Initialize method goes undetected, so the warning is disabled.
________________________________________