Chapter 6 discussed how all classes and structs ultimately derive from object. In addition, it reviewed each method available on object and discussed how some of them are virtual. This section discusses the details concerning overriding these virtual methods. Doing so is automatic for records, but the generated code also provides an example of what is required if you choose to customize the generated code or implement an override on a non-record type implementation.
By default, calling ToString() on any object will return the fully qualified name of the object type. Calling ToString() on a System.IO.FileStream object will return the string System.IO.FileStream, for example. For some classes, however, ToString() can be more meaningful. On string, for example, ToString() returns the string value itself. Similarly, when invoking ToString() on an Angle (Listing 9.1), the result returns:
Angle { Degrees = 90, Minutes = 0, Seconds = 0, Name = }
(This also happens to be the output of Listing 9.2.)
Write methods such as System.Console.WriteLine() and System.Diagnostics.Trace.Write() call an object’s ToString() method,7 so overriding the method often outputs more meaningful information than the default implementation.
Overriding ToString() requires nothing more than declaring the ToString() method as override and returning a string. Take Listing 9.16 for example:
Regardless, do not return an empty string or null, as the lack of output will be very confusing. ToString() is useful for debugging from within a developer IDE or writing to a log file. Be mindful of implementing localization overload methods and other advanced formatting features to ensure suitability for general end-user text display. Also, keep the strings relatively short so that they are not truncated off the end of the screen – especially in during debugging in an IDE.
Consider overriding the ToString() method whenever relevant diagnostic information can be provided from the output—specifically, when the target audience is developers, since the default object.ToString() output is a type name and is not end-user friendly. Alternatively, starting in C# 10.0, you can seal a record’s ToString() implementation, preventing its override by derived-type—including a code generated implementation.
Frequently, developers expect implementing value equivalence to be trivial. How hard could it be to compare two objects? However, it includes a surprising number of subtleties and complexities that require careful thought and testing. Fortunately, there are two approaches that significantly simplify the code. Firstly, if you use a record construct, then the C# compiler takes care of generating the Equals() method and all its associated members for you, comparing all automatically implemented properties and fields of the record—whether they are included as positional parameters or not. Secondly, you can group the identifying data elements (automatically implemented properties and fields) into a tuple and then compare the tuples. In the case of reference type, you also need to pay attention to any potential null values.
Listing 9.3 shows the implementation of Equals() that was generated for a record and enables value equality. And one of the big advantages of C# records is that they generate a value equality implementation for you, relieving you of the exercise of manually implementing what could be a surprisingly complex method considering inheritance, null, varying data types, and value/reference type in addition to comparing the contained values themselves. That said, if the record-generated implementation doesn’t suit your needs, then you will need to write your own. One reason to do that is that you may not want some of the properties to be included as part of the value equals determination. For example, perhaps you don’t want the Name property on Angle to be part of an Angle’s value identity, instead relying solely on Degrees, Minutes, and Seconds. Listing 9.24 provides such a full customized record struct with the following snippet of a custom equality implementation in Listing 9.17:
Notice that the value-identifying properties are combined into tuples and compared with the same values of other. In addition, be aware that the equals implementation is overloaded. The previous snippet shows the implementation with an Angle parameter. That is because the C# compiler generates all the additional value equality functionality for you. However, this is not the case on types that are not records—you must provide all such customization yourself. Also, whenever you update the Equals() method, you should update GetHashCode(), so use the same data elements. A failure to override GetHashCode() when overriding Equals(), and vice -versa, will generate a warning:
CS8851 '<ClassName>' defines 'Equals' but not 'GetHashCode'
While this snippet looks different from the record-generated version of Equals() shown in Listing 9.3, internally the tuple (System.ValueTuple<...>) uses EqualityComparer<T> as well. In turn, EqualityComparer<T> relies on the type parameters implementation of IEquatable<T> (which contains only a single Equals<T>(T other) member). Therefore, to correctly override Equals, you need to implement IEquatable<T>.
Once Equals() is overridden, there is a possible inconsistency. That is, two objects could return true for Equals() but false for the == operator because == performs a reference equality check by default. To correct this flaw, it is important to override the equals (==) and not equals (!=) operators as well. Of course, this code is generated for you as part of the record implementation as you can see in Listing 9.18:
For the most part, the implementation for these operators can delegate the logic to Equals(), or vice versa. Take care, however, not to invoke an infinite recursive loop with the == operator calling back on itself. In this case, such a recursion is avoided because we use ReferenceEquals() initially, which will return true if both values are null. For value types, there is, of course, no need to check for null. See Chapter 10 for more information on operator overloading.
Lastly, consider implementing IComparable<T> if the custom type can be sorted along with the IFormattable interface if localization is needed. For the full implementation of value equality without using a record construct, see “Advanced Topic: Overriding GetHashCode().” There are two more important topics related to value types that we still need to cover. The first is boxing, and the second is enums, and both are discussed in the following sections.
Notice that the signature of Equals() in Listing 9.24 does not match the signature object in Listing 9.19.
(If it did, we would need to use the override modifier.) When implementing Equals() on a class or struct (that are not records), there are several other considerations to be aware of. All are available in Listing 9.3 for a struct and Listing 9.5 for a class (because the record modifier generates them).
The steps for implementing value equality on a custom type are as follows:
Steps 3 to 4 occur in an overload of Equals() that takes the custom data type specifically (such as Coordinate). This way, a comparison of two Coordinates will avoid Equals(object? obj) and its type check altogether.
Equals() should never throw any exceptions. It is a valid choice to compare any object with any other object, and doing so should never result in an exception.
If you rely on a record construct, GetHashCode() is automatically implemented for you as part of the value equality implementation (see Listing 9.3 and Listing 9.5). Without the record implementation, you have to implement GetHashCode() on your own, however, if you are providing an equality implementation. Even with a record, if you customize the Equals() implementation, you will likely want to override GetHashCode() to use a similar set of values as the new Equals() implementation. And, if you override only Equals() and not GetHashCode(), you will have a warning that:
CS0659: '<Class Name>' overrides Object.Equals(object o) but
does not override Object.GetHashCode(),
In other words, when not leveraging the record construct, overriding equals requires that you also override GetHashCode().
The purpose of the hash code is to efficiently balance a hash table by generating a number that corresponds to the value of an object. And, while there are numerous guidelines (see https://bit.ly/39yP8lm for a discussion), the easiest approach is as follows:
In summary, use a record if overriding GetHashCode() and Equals() unless there is a strong reason not to (such as an older version of C# in which records are not available).
Fortunately, once you have determined that you need GetHashCode(), you can follow some well-established GetHashCode() implementation principles:
These guidelines and rules are, of course, contradictory: It is very difficult to come up with a hash algorithm that is fast and meets all of these guidelines. As with any design problem, you’ll need to use a combination of good judgment and realistic performance measurements to come up with a good solution. The easiest approach, of course, is to rely on one of the previously identified algorithms
________________________________________