IComparable, IEquatable and IDisposable

Three interfaces the framework looks for: implement them and sorting, equality and using blocks start working on your own types.

Syntaxint CompareTo(T? other) | bool Equals(T? other) | void Dispose()

Some interfaces are special because library code goes looking for them. Implement one and framework methods change behaviour on your type. Each one also binds you to a contract that nothing checks at compile time.

IComparable<T>: ordering

The interface is IComparable<in T> and its single member is int CompareTo(T? other). The angle brackets name the type you compare against; a later stage covers that notation. Return a negative number if this instance sorts before other, zero if they sort in the same place, and a positive number if it sorts after. Array.Sort and List<T>.Sort call it for you. The contract: the ordering must be consistent, so if A is before B and B is before C then A is before C.

IEquatable<T>: equality

IEquatable<T> declares bool Equals(T? other). List<T>.Contains, IndexOf and Remove use it, as does Dictionary<TKey, TValue>. The contract is the hard part: also override Equals(object?) and GetHashCode, and keep all three in agreement. Two equal values must return the same hash code.

IDisposable: clean-up

IDisposable declares void Dispose(), which releases a file handle, socket or similar. The using statement calls it for you, even if an error occurs. Its contract: a second call to Dispose must do nothing and must not throw.

Example

Example · csharp
Release[] releases =
{
    new Release(2, 1),
    new Release(1, 9),
    new Release(2, 0)
};

// Array.Sort finds IComparable<Release> and calls CompareTo.
Array.Sort(releases);

foreach (Release release in releases)
{
    Console.WriteLine(release.Label);
}

// This call lands on Equals(Release?) from IEquatable<Release>.
Console.WriteLine(new Release(2, 0).Equals(new Release(2, 0)));

sealed class Release : IComparable<Release>, IEquatable<Release>
{
    public Release(int major, int minor)
    {
        Major = major;
        Minor = minor;
    }

    public int Major { get; }

    public int Minor { get; }

    public string Label => $"{Major}.{Minor}";

    // Negative sorts first, zero sorts level, positive sorts later.
    public int CompareTo(Release? other)
    {
        if (other is null)
        {
            return 1;
        }

        int byMajor = Major.CompareTo(other.Major);
        return byMajor != 0 ? byMajor : Minor.CompareTo(other.Minor);
    }

    public bool Equals(Release? other) =>
        other is not null && Major == other.Major && Minor == other.Minor;

    // Kept in step with Equals(Release?), as the contract requires.
    public override bool Equals(object? obj) => Equals(obj as Release);

    public override int GetHashCode() => HashCode.Combine(Major, Minor);
}

// Output:
// 1.9
// 2.0
// 2.1
// True

When to use it

  • Sort a list of your own version or money objects with Array.Sort instead of a hand-written loop.
  • Use a composite key type in a Dictionary and have lookups actually find it.
  • Wrap a file or connection in a type that a using block closes even when the body throws.

More examples

IDisposable and the three ways to call Dispose

Any type holding a file, socket or database connection implements IDisposable. Seeing the try/finally that using expands into explains why Dispose still runs when the block throws, and the guard flag is what keeps a repeated call harmless.

Example · csharp
// The using statement: Dispose runs when the block ends, error or not.
using (Session session = new Session("orders"))
{
    session.Run("select 1");
}

// The using declaration, C# 8 shorthand. Dispose runs at the end of the
// enclosing scope instead of a block of its own:
//     using Session session = new Session("orders");
//
// The longhand both forms compile into:
//     Session session = new Session("orders");
//     try
//     {
//         session.Run("select 1");
//     }
//     finally
//     {
//         session.Dispose();
//     }

sealed class Session : IDisposable
{
    private readonly string _name;
    private bool _disposed;

    public Session(string name)
    {
        _name = name;
        Console.WriteLine($"opened {_name}");
    }

    public void Run(string command)
    {
        // Added in .NET 7. Throws ObjectDisposedException when the flag is set.
        ObjectDisposedException.ThrowIf(_disposed, this);
        Console.WriteLine($"{_name}: {command}");
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;   // a second call must do nothing and must not throw
        }

        _disposed = true;
        Console.WriteLine($"closed {_name}");
    }
}

// Output:
// opened orders
// orders: select 1
// closed orders

Equality that a Dictionary can rely on

A dictionary uses the hash code to pick a bucket and Equals to confirm the match. Build both from the same properties whenever a type will be used as a key or searched for in a list, or lookups fail in ways that look like data loss.

Example · csharp
var stock = new Dictionary<Sku, int>
{
    [new Sku("LAMP", 2)] = 7
};

// Found because Equals and GetHashCode agree on what makes two Skus the same.
Console.WriteLine(stock[new Sku("LAMP", 2)]);

var basket = new List<Sku> { new Sku("LAMP", 2) };
Console.WriteLine(basket.Contains(new Sku("LAMP", 2)));

sealed class Sku : IEquatable<Sku>
{
    public Sku(string code, int variant)
    {
        Code = code;
        Variant = variant;
    }

    public string Code { get; }

    public int Variant { get; }

    public bool Equals(Sku? other) =>
        other is not null && Code == other.Code && Variant == other.Variant;

    public override bool Equals(object? obj) => Equals(obj as Sku);

    // Same inputs as Equals, so equal objects always share a hash code.
    public override int GetHashCode() => HashCode.Combine(Code, Variant);
}

// Output:
// 7
// True

The shorthand: let a record write the equality members

Records arrived in C# 9 and are the short way to get the hand-written equality from the previous example. Write the members yourself only when equality means something other than all properties matching, such as comparing on an id alone.

Example · csharp
// A record implements IEquatable<T> and writes Equals, GetHashCode,
// == and != for you, from the properties you declared.
record Sku(string Code, int Variant);

Sku a = new Sku("LAMP", 2);
Sku b = new Sku("LAMP", 2);

Console.WriteLine(a.Equals(b));
Console.WriteLine(a == b);

// IEquatable<Sku> is implemented, so this cast compiles.
IEquatable<Sku> equatable = a;
Console.WriteLine(equatable.Equals(b));

// Output:
// True
// True
// True

Discussion

  • Be the first to comment on this lesson.