Customizing Record Behavior

Clearly, the C# compiler generates a significant amount of code for the record struct and record class constructs. However, all the behavior is customizable. As mentioned earlier in the chapter, for example, you can add any additional members, including properties, fields, constructors, and methods. More importantly, you can provide your own versions of the generated members. Coding any record member with a matching signature to the otherwise synthesized member, you can replace the default behavior with your own. If you prefer a read-only property rather than an init-only setter property, you simply declare the property (or field) to match the positional property name. Or, by providing your own copy constructor, you can inject custom behavior into how a record class handles cloning (via the with operator). Similarly, if you want to change the semantics of equality, perhaps to simplify it to a subset of the record’s properties/fields, you can define your own Equals() method, likely only the method that takes a single parameter of the containing type. Listing 9.24 provides several examples of possible customizations on a record.

Listing 9.24: Customizing a Record
public readonly record struct Angle(
    int Degrees, int Minutes, int Seconds, string? Name = null)
{
 
    public int Degrees { get; } = Degrees;
 
    public Angle(
        string degrees, string minutes, string seconds)
        : this(int.Parse(degrees), 
              int.Parse(minutes), int.Parse(seconds))
    {
    }
    
    public override readonly string ToString()
    {
        string prefix = 
            string.IsNullOrWhiteSpace(Name)?string.Empty : Name+": ";
        return $"{prefix}{Degrees}° {Minutes}{Seconds}\"";
    }
 
    // Changing Equals() to ignore Name
    public bool Equals(Angle other) =>
        (Degrees, Minutes, Seconds).Equals(
            (other.Degrees, other.Minutes, other.Seconds));
 
    public override int GetHashCode() =>
        HashCode.Combine(Degrees.GetHashCode(), 
            Minutes.GetHashCode(), Seconds.GetHashCode);
 
    #if UsingTupleToGenerateHashCode
    public override int GetHashCode() => 
        (Degrees, Minutes, Seconds).GetHashCode();
    #endif // UsingTupleToGenerateHashCode        
}

Boxing

We know that variables of value types directly contain their data, whereas variables of reference types contain a reference to another storage location. But what happens when a value type is converted to one of its implemented interfaces or to its root base class, object? The result of the conversion must be a reference to a storage location that contains something that looks like an instance of a reference type, but the variable contains a value of value type. Such a conversion, which is known as boxing, has special behavior. Converting a variable of a value type that directly refers to its data to a reference type that refers to a location on the garbage-collected heap involves several steps.

1.
Memory is allocated on the heap that will contain the value type’s data and the other overhead necessary to make the object look like every other instance of a managed object of the reference type (namely, a SyncBlockIndex and method table pointer).
2.
The value of the value type is copied from its current storage location into the newly allocated location on the heap.
3.
The result of the conversion is a reference to the new storage location on the heap.

The reverse operation is called unboxing. The unboxing conversion first checks whether the type of the boxed value is the same as the type to which the value is being unboxed, and then results in a copy of the value stored in the heap location.

Boxing and unboxing are important to consider because boxing has some performance and behavioral implications. Besides learning how to recognize these conversions within C# code, a developer can count the box/unbox instructions in a particular snippet of code by looking through the Common Intermediate Language (CIL). Each operation has specific instructions, as shown in Table 9.1.

Table 9.1: Boxing Code in CIL

C# Code

CIL Code

static void Main()

{

     int number;

     object thing;

     number = 42;

     // Boxing

     thing = number;

     // Unboxing

     number = (int)thing;

return;

}

.method private hidebysig

     static void Main() cil managed

{

   .entrypoint

   // Code size 21 (0x15)

   .maxstack 1

   .locals init ([0] int32 number,

            [1] object thing)

   IL_0000: nop

   IL_0001: ldc.i4.s 42

   IL_0003: stloc.0

   IL_0004: ldloc.0

   IL_0005: box [mscorlib]System.Int32

   IL_000a: stloc.1

   IL_000b: ldloc.1

   IL_000c: unbox.any [mscorlib]System.Int32

   IL_0011: stloc.0

   IL_0012: br.s IL_0014

   IL_0014: ret

} // end of method Program::Main

When boxing and unboxing occur infrequently, their implications for performance are irrelevant. However, boxing can occur in some unexpected situations, and frequent occurrences can have a significant impact on performance. Consider Listing 9.25 and Output 9.1. The ArrayList type maintains a list of references to objects, so adding an integer or floating-point number to the list will box the value so that a reference can be obtained.

Listing 9.25: Subtle Box and Unbox Instructions
public class DisplayFibonacci
{
    public static void Main()
    {
        // Intentionally using ArrayList to demonstrate boxing
        System.Collections.ArrayList list = new();
 
        Console.Write("Enter an integer between 2 and 1000: ");
        string? inputText = Console.ReadLine();
        if (!int.TryParse(inputText, out int totalCount))
        {
            Console.WriteLine($"'{inputText}' is not a valid integer.");
            return;
        }
 
        if (totalCount == 7)  // Magic number used for testing
        {
            // Triggers exception when retrieving  value as double.
            list.Add(0);  // Cast to double or 'D' suffix required.
                          // Whether cast or using 'D' suffix,
                          // CIL is identical.
 
        }
        else
        {
            list.Add((double)0);
        }
 
        list.Add((double)1);
        
        for(int count = 2; count < totalCount; count++)
        {
            list.Add(
                (double)list[count - 1]! +
                (double)list[count - 2]!);
        }
 
        // Using a foreach to clarify the box operation rather than
        // Console.WriteLine(string.Join(", ", list.ToArray()));
        foreach (double? count in list)
        {
            Console.Write("{0}, ", count);
        }
    }
}
Output 9.1
Enter a number between 2 and 1000: 42
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155, 165580141,

The code shown in Listing 9.9, when compiled, produces five box instructions and three unbox instructions in the resultant CIL.

1.
The first two box instructions occur in the initial calls to list.Add(). The signature for the ArrayList9 method is int Add(object value). As such, any value type passed to this method is boxed.
2.
Next are two unbox instructions in the call to Add() within the for loop. The return from an ArrayList’s index operator is always object because that is what ArrayList contains. To add the two values, you need to cast them back to doubles. This cast from a reference to an object to a value type is implemented as an unbox call.
3.
Now you take the result of the addition and place it into the ArrayList instance, which again results in a box operation. Note that the first two unbox instructions and this box instruction occur within a loop.
4.
In the foreach loop, you iterate through each item in ArrayList and assign the items to count. As you saw earlier, the items within ArrayList are references to objects, so assigning them to a double is, in effect, unboxing each of them.
5.
The signature for Console.WriteLine(), which is called within the foreach loop, is void Console.Write(string format, object arg). As a result, each call to it boxes the double to object.

Every boxing operation involves both an allocation and a copy; every unboxing operation involves a type check and a copy. Doing the equivalent work using the unboxed type would eliminate the allocation and type check. Obviously, you can easily improve this code’s performance by eliminating many of the boxing operations. Using an object rather than double in the last foreach loop is one such improvement. Another would be to change the ArrayList data type to a generic collection (see Chapter 12). The point being made here is that boxing can be rather subtle, so developers need to pay special attention and notice situations where it could potentially occur repeatedly and affect performance.

Another unfortunate boxing-related problem also occurs at runtime: When calling Add() without first casting to a double (or using a double literal), you could insert integers into the array list. Since ints will implicitly be converted to doubles, this would appear to be an innocuous modification. However, the casts to double when retrieving the value from within the foreach loop would fail. The problem is that the unbox operation is immediately followed by an attempt to perform a memory copy of the value of the boxed int into a double. You cannot do this without first casting to an int, because the code will throw an InvalidCastException at execution time. Listing 9.26 shows a similar error commented out and followed by the correct cast.

Listing 9.26: Unboxing Must Be to the Underlying Type
// ...
 
int number;
object thing;
double bigNumber;
 
number = 42;
thing = number;
// ERROR: InvalidCastException
// bigNumber = (double)thing;
bigNumber = (double)(int)thing;
// ...
AdVanced Topic
Value Types in the lock Statement

C# supports a lock statement for synchronizing code. This statement compiles down to System.Threading.Monitor’s Enter() and Exit() methods, which must be called in pairs. Enter() records the unique reference argument passed so that when Exit() is called with the same reference, the lock can be released. The trouble with using value types is the boxing. Each time Enter() or Exit() is called in such a case, a new value is created on the heap. Comparing the reference of one copy to the reference of a different copy will always return false, so you cannot hook up Enter() with the corresponding Exit(). Therefore, value types in the lock() statement are not allowed.

Listing 9.27 points out a few more runtime boxing idiosyncrasies, and Output 9.2 shows the results.

Listing 9.27: Subtle Boxing Idiosyncrasies
interface IAngle
{
    void MoveTo(int degrees, int minutes, int seconds);
}
 
struct Angle : IAngle
{
    // ...
 
    // NOTE:  This makes Angle mutable, against the general
    //        guideline
    public void MoveTo(int degrees, int minutes, int seconds)
    {
        _Degrees = degrees;
        _Minutes = minutes;
        _Seconds = seconds;
    }
    // ...
}
public class Program
{
    public static void Main()
    {
        // ...
 
        Angle angle = new(25, 58, 23);
        // Example 1: Simple box operation
        object objectAngle = angle;  // Box
        Console.Write(((Angle)objectAngle).Degrees);
 
        // Example 2: Unbox, modify unboxed value,
        //            and discard value
        ((Angle)objectAngle).MoveTo
            (26, 58, 23);
        Console.Write(", " + ((Angle)objectAngle).Degrees);
 
        // Example 3: Box, modify boxed value,
        //            and discard reference to box
        ((IAngle)angle).MoveTo(26, 58, 23);
        Console.Write(", " + ((Angle)angle).Degrees);
 
        // Example 4: Modify boxed value directly
        ((IAngle)objectAngle).MoveTo(26, 58, 23);
        Console.WriteLine(", " + ((Angle)objectAngle).Degrees);
 
        // ...
    }
}
Output 9.2
25, 25, 25, 26

Listing 9.27 uses the Angle struct and IAngle interface. Note also that the IAngle.MoveTo() interface changes Angle to be mutable. This change brings out some of the idiosyncrasies of mutable value types and, in so doing, demonstrates the importance of the guideline that advocates making structs immutable.

In Example 1 of Listing 9.27, after you initialize angle, you then box it into a variable called objectAngle. Next, Example 2 calls MoveTo() to change _Degrees to 26. However, as the output demonstrates, no change actually occurs the first time. The problem is that to call MoveTo(), the compiler unboxes objectAngle and (by definition) makes a copy of the value. Value types are copied by value—that is why they are called value types. Although the resultant value is successfully modified at execution time, this copy of the value is discarded and nos change occurs on the heap location referenced by objectAngle.

Recall our analogy that suggested variables of value types are like pieces of paper with the value written on them. When you box a value, you make a photocopy of the paper and put the copy in a box. When you unbox the value, you make a photocopy of the paper in the box. Making an edit to this second copy does not change the copy that is in the box.

In Example 3, a similar problem occurs, but in reverse. Instead of calling MoveTo() directly, the value is cast to IAngle. The conversion to an interface type boxes the value, so the runtime copies the data in angle to the heap and provides a reference to that box. Next, the method call modifies the value in the referenced box. The value stored in variable angle remains unmodified.

In the last case, the cast to IAngle is a reference conversion, not a boxing conversion. The value has already been boxed by the conversion to object in this case, so no copy of the value occurs on this conversion. The call to MoveTo() updates the _Degrees value stored in the box, and the code behaves as desired.

As you can see from this example, mutable value types are quite confusing because it is often unclear when you are mutating a copy of the value rather than the storage location you actually intend to change. By avoiding mutable value types in the first place, you can eliminate this sort of confusion.

Guidelines
AVOID mutable value types.

AdVanced Topic
How Boxing Can Be Avoided during Method Calls

Anytime a method is called on a value type, the value type receiving the call (represented by this in the body of the method) must be a variable, not a value, because the method might be trying to mutate the receiver. Clearly, it must be mutating the receiver’s storage location, rather than mutating a copy of the receiver’s value and then discarding it. Examples 2 and 4 of Listing 9.11 illustrate how this fact affects the performance of a method invocation on a boxed value type.

In Example 2, the unboxing conversion logically produces the boxed value, not a reference to the storage location on the heap that contains the boxed copy. Which storage location, then, is passed via this to the mutating method call? It cannot be the storage location from the box on the heap, because the unboxing conversion produces a copy of that value, not a reference to that storage location.

When this situation arises—a variable of a value type is required but only a value is available—one of two things happens: either the C# compiler generates code that makes a new, temporary storage location and copies the value from the box into the new location, resulting in the temporary storage location becoming the needed variable, or the compiler produces an error and disallows the operation. In this case, the former strategy is used. The new temporary storage location is then the receiver of the call; after it is mutated, the temporary storage location is discarded.

This process—performing a type check of the boxed value, unboxing to produce the storage location of the boxed value, allocating a temporary variable, copying the value from the box to the temporary variable, and then calling the method with the location of the temporary storage—happens every time you use the unbox-and-then-call pattern, regardless of whether the method actually mutates the variable. Clearly, if it does not mutate the variable, some of this work could be avoided. Because the C# compiler does not know whether any particular method you call will try to mutate the receiver, it must err on the side of caution.

These expenses are all eliminated when calling an interface method on a boxed value type. In such a case, the expectation is that the receiver will be the storage location in the box; if the interface method mutates the storage location, it is the boxed location that should be mutated. Therefore, the expense of performing a type check, allocating new temporary storage, and making a copy is avoided. Instead, the runtime simply uses the storage location in the box as the receiver of the call to the struct’s method.

In Listing 9.28, we call the two-argument version of ToString() that is found on the IFormattable interface, which is implemented by the int value type. In this example, the receiver of the call is a boxed value type, but it is not unboxed to make the call to the interface method.

Listing 9.28: Avoiding Unboxing and Copying
int number;
object thing;
number = 42;
// Boxing
thing = number;
// No unboxing conversion
string text = ((IFormattable)thing).ToString(
    "X"null);
Console.WriteLine(text);

Now suppose that we had instead called the virtual ToString() method declared by object with an instance of a value type as the receiver. What happens then? Is the instance boxed, unboxed, or something else? A number of different scenarios are possible depending on the details:

If the receiver is unboxed and the struct overrides ToString(), the overridden method is called directly. There is no need for a virtual call because the method cannot be overridden further by a more derived class; all value types are automatically sealed.
If the receiver is unboxed and the struct does not override ToString(), the base class implementation must be called, and it expects a reference to an object as its receiver. Therefore, the receiver is boxed.
If the receiver is boxed and the struct overrides ToString(), the storage location in the box is passed to the overriding method without unboxing it.
If the receiver is boxed and the struct does not override ToString(), the reference to the box is passed to the base class’s implementation of the method, which is expecting a reference.

________________________________________

9. It is important that we use a collection of type object, not a strongly typed collection like a generic collection, as discussed in Chapter 12.
{{ snackbarMessage }}
;