10

Well Formed Types

The previous chapters covered most of the constructs for defining records and value types (structs and enums). However, several details remain to round out the type definition with fit-and-finish-type functionality. This chapter explains how to put the final touches on a type declaration. This includes such concepts as operator overloading, referencing other libraries, additional access modifiers, namespaces, inline documentation with XML comments, and resource management.

Operator Overloading

The preceding chapter looked art implementing value equality behavior and provided the guideline that the class should also implement == and !=. Implementing any operator is called operator overloading. This section describes how to perform such overloading not only for == and !=, but also for other supported operators.

For example, string provides a + operator that concatenates two strings. This is perhaps not surprising, because string is a predefined type, so it could possibly have special compiler support. However, C# provides for adding + operator support to a class or struct. In fact, all operators are supported except x.y, f(x), new, typeof, default, checked, unchecked, delegate, is, as, =, and =>. One particularly noteworthy operator that cannot be implemented is the assignment operator; there is no way to change the behavior of the = operator.

Before going through the exercise of implementing an operator overload, consider the fact that such operators are not discoverable through IntelliSense. Unless the intent is for a type to act like a primitive type (e.g., a numeric type), you should avoid overloading an operator as the only mean of accessing the behavior.

Comparison Operators (==, !=, <, >, <=, >=)

In Chapter 9 we reviewed the code generated for a record’s implementation of the == and != operator. In addition, you can override the greater and less than operators, including when combined with equality checks. (see Listing 10.1).

Listing 10.1: Implementing the == and != Operators
public sealed class ProductSerialNumber
{
    // ...
 
    public static bool operator ==(
        ProductSerialNumber leftHandSide,
        ProductSerialNumber rightHandSide)
    {
 
        // Check if leftHandSide is null
        // (operator == would be recursive)
        if(leftHandSide is null)
        {
            // Return true if rightHandSide is also null
            // and false otherwise
            return rightHandSide is null;
        }
 
        return (leftHandSide.Equals(rightHandSide));
    }
 
    public static bool operator !=(
        ProductSerialNumber leftHandSide,
        ProductSerialNumber rightHandSide)
    {
        return !(leftHandSide == rightHandSide);
    }
}

Note that the compiler enforces implementing these comparison operators in pairs: (==, !=), (<, >), (<=, >=). The implementation in Listing 10.1 relies on one pair element calling the other. Also, for reference types, be sure to handle null appropriately – before invoking any of the members. You should never throw an exception from within an operator overloading implementation.

Guidelines
DO NOT throw exceptions from within the implementation of operator overloading.
Binary Operators (+, -, *, /, %, &, |, ^, <<, >>)

You can add an Arc to a Coordinate. However, the code so far provides no support for the addition operator. Instead, you need to define such a method, as Listing 10.2 demonstrates.

Listing 10.2: Adding an Operator
public readonly struct Arc
{
    public Arc(
        Longitude longitudeDifference,
        Latitude latitudeDifference)
    {
        LongitudeDifference = longitudeDifference;
        LatitudeDifference = latitudeDifference;
    }
 
    public Longitude LongitudeDifference { get; }
    public Latitude LatitudeDifference { get; }
 
}
 
public readonly struct Coordinate
{
    // ...
    public static Coordinate operator +(
        Coordinate source, Arc arc)
    {
        Coordinate result = new(
            new Longitude(
                source.Longitude + arc.LongitudeDifference),
            new Latitude(
                source.Latitude + arc.LatitudeDifference));
        return result;
    }
    // ...
}

The +, -, *, /, %, &, |, ^, <<, and >> operators are implemented as binary static methods, where at least one parameter is of the containing type. The method name is the operator symbol prefixed by the keyword operator. As shown in Listing 10.3 with Output 10.1, given the definition of the - and + binary operators, you can add and subtract an Arc to and from the coordinate. Note that Longitude and Latitude will also require implementations of the + operator because they are called by source.Longitude + arc.LongitudeDifference and source.Latitude + arc.LatitudeDifference.

Listing 10.3: Calling the and + Binary Operators
// Use compound assignment suppressed for demonstration purposes
#pragma warning disable IDE0054
public class Program
{
    public static void Main()
    {
        Coordinate coordinate1, coordinate2;
        coordinate1 = new Coordinate(
            new Longitude(48, 52), new Latitude(-2, -20));
        Arc arc = new(new Longitude(3), new Latitude(1));
 
        coordinate2 = coordinate1 + arc;
        Console.WriteLine(coordinate2);
 
        coordinate2 = coordinate2 - arc;
        Console.WriteLine(coordinate2);
 
        coordinate2 += arc;
        Console.WriteLine(coordinate2);
    }
}

Output 10.1
51° 52' 0 E  -1° -20' 0 N
48° 52' 0 E  -2° -20' 0 N
51° 52' 0 E  -1° -20' 0 N

For Coordinate, you implement the and + operators to return coordinate locations after adding/subtracting Arc. This allows you to string multiple operators and operands together, as in result = ((coordinate1 + arc1) + arc2) + arc3. Moreover, by supporting the same operators (+/-) on Arc (see Listing 10.4 later in this chapter), you could eliminate the parentheses. This approach works because the result of the first operand (arc1 + arc2) is another Arc, which you can then add to the next operand of type Arc or Coordinate.

In contrast, consider what would happen if you provided a operator that had two Coordinates as parameters and returned a double corresponding to the distance between the two coordinates. Adding a double to a Coordinate is undefined, so you could not string together operators and operands. Caution is in order when defining operators that return a different type, because doing so is counterintuitive.

Combining Assignment with Binary Operators (+=, -=, *=, /=, %=, &=, …)

As previously mentioned, there is no support for overloading the assignment operator. However, assignment operators in combination with binary operators (+=, -=, *=, /=, %=, &=, |=, ^=, <<=, and >>=) are effectively overloaded when overloading the binary operator. Given the definition of a binary operator without the assignment, C# automatically allows for assignment in combination with the operator. Using the definition of Coordinate in Listing 10.2, therefore, you can have code such as

coordinate += arc;

which is equivalent to the following:

coordinate = coordinate + arc;

Conditional Logical Operators (&&, ||)

Like assignment operators, conditional logical operators cannot be overloaded explicitly. However, because the logical operators & and | can be overloaded, and the conditional operators comprise the logical operators, effectively it is possible to overload conditional operators. x && y is processed as x & y, where y must evaluate to true. Similarly, x || y is processed as x | y only if x is false. To enable support for evaluating a type to true or false—in an if statement, for example—it is necessary to override the true/false unary operators.

Unary Operators (+, -, !, ~, ++, --, true, false)

Overloading unary operators is very similar to overloading binary operators, except that they take only one parameter, also of the containing type. Listing 10.4 overloads the + and operators for Longitude and Latitude and then uses these operators when overloading the same operators in Arc.

Listing 10.4: Overloading the and + Unary Operators
public struct Latitude
{
    // ...
    public static Latitude operator -(Latitude latitude)
    {
        return new Latitude(-latitude.Degrees);
    }
 
    public static Latitude operator +(Latitude latitude)
    {
        return latitude;
    }
 
    // ...
}
 
public struct Longitude
{
    // ...
    public static Longitude operator -(Longitude longitude)
    {
        return new Longitude(-longitude.Degrees);
    }
 
    public static Longitude operator +(Longitude longitude)
    {
        return longitude;
    }
    // ...
}
public readonly struct Arc
{
    // ...
    public static Arc operator -(Arc arc)
    {
        // Uses unary – operator defined on 
        // Longitude and Latitude
        return new Arc(-arc.LongitudeDifference,
            -arc.LatitudeDifference);
    }
 
    public static Arc operator +(Arc arc)
    {
        return arc;
    }
}

Just as with numeric types, the + operator in this listing doesn’t have any effect and is provided for symmetry.

Overloading true and false is subject to the additional requirement that both must be overloaded—not just one of the two. The signatures are the same as with other operator overloads; however, the return must be a bool, as demonstrated in Listing 10.5.

Listing 10.5: Overloading the true and false Operators
public static bool operator false(object item)
{
    // ...
}
public static bool operator true(object item)
{
    // ...
}

You can use types with overloaded true and false operators in if, do, while, and for controlling expressions.

Conversion Operators

Currently, there is no support in Longitude, Latitude, and Coordinate for casting to an alternative type. For example, there is no way to cast a double into a Longitude or Latitude instance. Similarly, there is no support for assigning a Coordinate using a string. Fortunately, C# provides for the definition of methods specifically intended to handle the converting of one type to another. Furthermore, the method declaration allows for specifying whether the conversion is implicit or explicit.

AdVanced Topic
Cast Operator (())

Implementing the explicit and implicit conversion operators is not technically overloading the cast operator (()). However, this action is effectively what takes place, so defining a cast operator is common terminology for implementing explicit or implicit conversion.

Defining a conversion operator is similar in style to defining any other operator, except that the “operator” is the resultant type of the conversion. Additionally, the operator keyword follows a keyword that indicates whether the conversion is implicit or explicit (see Listing 10.6).

Listing 10.6: Providing an Implicit Conversion between Latitude and double
public readonly struct Latitude
{
    // ...
 
    public Latitude(double decimalDegrees)
    {
        DecimalDegrees = Normalize(decimalDegrees);
    }
 
    public double DecimalDegrees { get; }
 
    // ...
 
    public static implicit operator double(Latitude latitude)
    {
        return latitude.DecimalDegrees;
    }
    public static implicit operator Latitude(double degrees)
    {
        return new Latitude(degrees);
    }
 
    // ...
}

With these conversion operators, you now can convert doubles implicitly to and from Latitude objects. Assuming similar conversions exist for Longitude, you can simplify the creation of a Coordinate object by specifying the decimal degrees portion of each coordinate portion (e.g., coordinate = new Coordinate(43, 172);).

note
When implementing a conversion operator, either the return or the parameter must be of the enclosing type—in support of encapsulation. C# does not allow you to specify conversions outside the scope of the converted type.
Guidelines for Conversion Operators

The difference between defining an implicit and an explicit conversion operator centers on preventing an unintentional implicit conversion that results in undesirable behavior. You should be aware of two possible consequences of using the explicit conversion operator. First, conversion operators that throw exceptions should always be explicit. For example, it is highly likely that a string will not conform to the format that a conversion from string to Coordinate requires. Given the chance of a failed conversion, you should define the particular conversion operator as explicit, thereby requiring that you be intentional about the conversion and ensure that the format is correct or, alternatively, that you provide code to handle the possible exception. Frequently, the pattern for conversion is that one direction (string to Coordinate) is explicit and the reverse (Coordinate to string) is implicit.

A second consideration is that some conversions will be lossy. Converting from a float (4.2) to an int is entirely valid, assuming an awareness of the fact that the decimal portion of the float will be lost. Any conversions that will lose data and will not successfully convert back to the original type should be defined as explicit. If an explicit cast is unexpectedly lossy or invalid, consider throwing a System.InvalidCastException.

Guidelines
DO NOT provide an implicit conversion operator if the conversion is lossy.
DO NOT throw exceptions from implicit conversions.
{{ snackbarMessage }}