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:
(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:
Listing 9.16: Overriding the ToString Method
string prefix =
string.IsNullOrWhiteSpace(Name) ? string.Empty : Name + ": ";
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. The lack of localization and other advanced formatting features makes this approach less suitable for general end-user text display. For this reason, you should keep the strings relatively short (one screen width) so that they are not truncated off the end of the screen.
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.
DO override ToString() whenever useful developer-oriented diagnostic strings can be returned.
CONSIDER trying to keep the string returned from ToString() short.
DO NOT return an empty string or null from ToString().
DO NOT throw exceptions or make observable side effects (change the object state) from ToString().
DO provide an overloaded ToString(string format) or implement IFormattable if the return value requires formatting or is culture-sensitive (e.g., DateTime).
CONSIDER returning a unique string from ToString() so as to identify the object instance.
Implementing Value Equality
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:
Listing 9.17: Custom Equality Implementation
publicbool Equals(Angle other) =>
(Degrees, Minutes, Seconds).Equals(
(other.Degrees, other.Minutes, other.Seconds));
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 and IFormattable 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.
Value Equality Principles
Notice that the signature of Equals() in Listing 9.24 does not match the signature object in Listing 9.19.
Listing 9.19: Nonmatching Signature
publicoverridebool Equals(object? obj)
(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:
Implement the IEquatable<T>8 interface.
Implement the Equals(object? obj) method, checking that obj is the same type as the custom type and invoking the strongly typed Equals() helper method that can treat the operand as the custom type rather than an object (Listing 9.20).
Listing 9.20: Implementing Value Equality on a Custom Type
publicoverridebool Equals(object? obj)
return Equals(obj as Foo);
Implement the strongly typed Equals() helper method, as shown in Listing 9.21, and check if obj is null (reference types only).
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.
DO implement GetHashCode(), Equals(), the == operator, and the != operator together—not one of these without the other three.
DO use the same algorithm when implementing Equals(), ==, and !=.
DO NOT throw exceptions from implementations of GetHashCode(), Equals(), ==, and !=.
AVOID overriding the equality-related members on mutable reference types or if the implementation would be significantly slower with such overriding.
DO implement all the equality-related methods when implementing IEquatable.
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 http://bit.ly/39yP8lmhttp://bit.ly/39yP8lm for a discussion), the easiest approaches are:
Rely on the record generated implementation (see Listing 9.2). If you need to override GetHashCode(), you are probably overriding Equals()and using a record is the best approach by default.
Call System.HashCode’s Combine() method, specifying each of the identifying fields (see Listing 9.22):
Listing 9.22: Overriding GetHashCode With Combine Method
publicoverrideint GetHashCode() =>
HashCode.Combine(Degrees, Minutes, Seconds);
Invoke ValueTuple’s GetHashCode() method using the fields that produce your object’s uniqueness as the tuple elements, as demonstrated in Listing 9.23. (If the identifying fields are numbers, be wary of mistakenly using the fields themselves rather than their hash code values.)
Listing 9.23: Overriding GetHashCode With Combine Method
publicoverrideint GetHashCode() =>
(Degrees, Minutes, Seconds).GetHashCode();
ValueTuple invokes HashCode.Combine(); thus, it may be easier to remember that you can adequately create a ValueTuple with the same identifying fields and invoke the resulting tuple’s GetHashCode() member
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:
Required: Equal objects must have equal hash codes (if a.Equals(b), then a.GetHashCode() == b.GetHashCode()).
Required:GetHashCode()’s returns over the life of a particular object should be constant (the same value), even if the object’s data changes. In many cases, you should cache the method return to enforce this constraint. However, when caching the value, be sure not to use the hash code when checking equality; if you do, two identical objects—one with a cached hash code of changed identity properties—will not return the correct result.
Required:GetHashCode() should not throw any exceptions; GetHashCode() must always successfully return a value.
Performance: Hash codes should be unique whenever possible. However, since hash codes return only an int, there inevitably will be an overlap in hash codes for objects that have potentially more values than an int can hold, which is virtually all types. (An obvious example is long, since there are more possible long values than an int could uniquely identify.)
Performance: The possible hash code values should be distributed evenly over the range of an int. For example, creating a hash that doesn’t consider the distribution of a string in Latin-based languages primarily centered on the initial 128 ASCII characters would result in a very uneven distribution of string values and would not be a strong GetHashCode() algorithm.
Performance:GetHashCode() should be optimized for performance. GetHashCode() is generally used in Equals() implementations to short-circuit a full equals comparison if the hash codes are different. As a result, it is frequently called when the type is used as a key type in dictionary collections.
Performance: Small differences between two objects should result in large differences between hash code values—ideally, a 1-bit difference in the object should result in approximately 16 bits of the hash code changing, on average. This helps ensure that the hash table remains balanced no matter how it is “bucketing” the hash values.
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 algorithms identified above.
Unless there is an implicit cast operator, as described in Advanced Topic: Cast Operator.