Building on our implementation of entity equality, we are now in a position to implement value equality. This is more complex because it tends to have a greater number of factors to consider.

Simple Value Types

If your value type contains just a single field (as we’ve already seen in many of the examples so far in this series) then the implementation can be fairly simple.

Consider the TimeSeriesId that we discussed previously:

public struct TimeSeriesId : IEquatable<TimeSeriesId>
{
    private readonly string _id;
    public TimeSeriesId(string id)
        => _id = IsValidId(id)
            ? id 
            : throw new ArgumentNullException(nameof(id));
    public static bool IsValidId(string id)
        => !string.IsNullOrWhiteSpace(id);
    public bool Equals(TimeSeriesId id)
        => string.Equals(_id, id._id, StringComparison.OrdinalIgnoreCase);
    public override bool Equals(object obj)
        => obj is TimeSeriesId id && Equals(id);
    public override int GetHashCode()
        => StringComparer.OrdinalIgnoreCase.GetHashCode(_id);
}

Equality is defined entirely on the basis of the sole private field, the _id wrapped by the type. As long as we keep the implementations of .Equals() and .GetHashCode() consistent with each other, we’re good to go.

Complex Value Types

For value types that contain multiple fields, things get just a smidgen more complicated. Consider the information needed to record a specific observation from a time series:

public class TimeSeriesObservation
{
    public TimeSeriesId TimeSeries { get; }
    public Party Subject { get; }
    public Date Observed { get; }
    public decimal Measure { get; }
}

Note this example is somewhat simplified to make it easy to discuss - actual time series observations can, in some contexts, be significantly more involved.

  • TimeSeries is the unique identifier of the series to which this observation belongs - notice the nested semantic type.

  • Subject is the unique identifier of the party to which this observation applies. This might be an individual, an incorporated company, a non-incorporated organization, or a defined group of any of these.

  • Observed is the date to which the observation applies. Note that we’re not using DateTimeOffset from the framework - we don’t want to have to deal with time.

  • Measure is the measurement of the series, captured as a decimal to avoid the rounding errors associated with floats and doubles.

It’s worth pausing to notice what we’re not including here. We’re assuming all measurements are simple numeric values, though this isn’t actually true for all series (e.g. credit ratings). Also, there is no allowance for observations to be revised, something we’d have to do in any real-world system.

As in prior discussions, we start by implementing the interface IEquatable<T>:

public bool Equals(TimeSeriesObservation other)
{
    if (ReferenceEquals(this, other))
    {
        return true;
    }

    return other != null
            && GetType() == other.GetType()
            && Equals(TimeSeries, other.TimeSeries)
            && Equals(Subject, other.Subject)
            && Equals(Observed, other.Observed)
            && Equals(Measure, other.Measure);
}

After supporting an easy shortcut, we’re careful here to test all of our fields. One of the more common bugs I’ve seen when reviewing implementations of value equality is the sin of omission - leaving a field out of the equality test.

We can then override .Equals() in the usual way:

public override bool Equals(object obj)
    => obj is TimeSeriesObservation o && Equals(o);

The implementation of .GetHashCode() has a few interesting quirks:

public bool Equals(TimeSeriesObservation other)
public override int GetHashCode()
{
    unchecked
    {
        return 3 * TimeSeries.GetHashCode()
                ^ 5 * Subject.GetHashCode()
                ^ 7 * Observed.GetHashCode()
                ^ 11 * Measure.GetHashCode();
    }
}

We combine together the existing implementations of .GetHashCode(), using an XOR operation.

To try and minimize the chances of different types cancelling each other out, we multiply each hash code by a different prime number. I feel that the exact prime numbers used aren’t particularly important, though disagree with me fervently. If you don’t like the idea of using small primes as shown here, choose a different three-digit prime for each field.

The calculation is done inside a unchecked block so that any integer overflow that happens doesn’t take the program down.

About this series

Why does the implementation of Equality matter in .NET and how do you do it right?

Posts in this series

Why is Equality important in .NET?
Types of Equality
Equality has Symmetry
Equality and GetHashCode
Implementing Entity Equality
Implementing Value Equality
Types behaving badly
Prior post in this series:
Implementing Entity Equality
Next post in this series:
Types behaving badly

Comments

blog comments powered by Disqus