Overriding ToString, Equals and GetHashCode

Every type inherits ToString, Equals and GetHashCode from object, and Equals and GetHashCode must agree or dictionaries break.

Syntaxpublic override bool Equals(object? obj) / public override int GetHashCode()

Every type in C# derives from object, so every type already has these members. Their declarations are public virtual string? ToString(), public virtual bool Equals(object? obj) and public virtual int GetHashCode(). All three are virtual, so any class can replace them.

The inherited versions are rarely what you want. ToString returns the type's full name. Equals compares identity: two objects are equal only if they are the same object in memory, so two separately created points holding 1 and 2 are not equal.

ToString

Override it to return a short readable description. Console.WriteLine and string interpolation both call it, so the override improves every log line and debugger view at once. Keep it cheap, never return null, and never let it throw.

Equals and GetHashCode travel together

A hash code is an int summary of an object's contents. A Dictionary or HashSet uses it to pick which bucket to look in, then uses Equals to confirm the match. So the rule is: if two objects are equal, they must return the same hash code. Breaking it means the dictionary looks in the wrong bucket and reports that a key it is holding is not there.

HashCode.Combine(X, Y) computes a good hash from your fields. The numbers it returns are not stable between runs of the program, so never store one.

What trips people up

Overriding Equals does not change ==. For a class, == still compares references unless the operator is defined too. A record, added in C# 9, generates all of this for you.

Example

Example · csharp
var a = new Point(1, 2);
var b = new Point(1, 2);

Console.WriteLine(a);                                    // uses the ToString override
Console.WriteLine(a.Equals(b));
Console.WriteLine(a.GetHashCode() == b.GetHashCode());

var counts = new Dictionary<Point, int>();
counts[a] = 10;
counts[b] += 5;      // the same entry, because a and b are equal and hash alike
Console.WriteLine(counts.Count);
Console.WriteLine(counts[a]);

sealed class Point(int x, int y)
{
    public int X { get; } = x;
    public int Y { get; } = y;

    public override string ToString() => $"({X}, {Y})";

    public override bool Equals(object? obj) =>
        obj is Point other && other.X == X && other.Y == Y;

    // Must agree with Equals: equal objects return the same number
    public override int GetHashCode() => HashCode.Combine(X, Y);
}

// Output:
// (1, 2)
// True
// True
// 1
// 15

When to use it

  • Make log lines readable by giving a domain type a ToString that prints its identifier.
  • Use a composite key type in a Dictionary and have two separately built keys match.
  • De-duplicate a list of imported records with a HashSet based on their business fields.

More examples

What breaks when GetHashCode disagrees with Equals

This is the bug report. The class looks correct, Equals returns true for the two tags, and the HashSet still says it has never seen one. Overriding Equals without GetHashCode produces a warning from the compiler for exactly this reason. Deleting the override of Equals or adding GetHashCode both fix it.

Example · csharp
var set = new HashSet<Tag>();
set.Add(new Tag("urgent"));

// Equal by Equals, but the hash codes differ, so the lookup checks
// the wrong bucket and finds nothing.
Console.WriteLine(set.Contains(new Tag("urgent")));

sealed class Tag(string name)
{
    public string Name { get; } = name;

    public override bool Equals(object? obj) => obj is Tag other && other.Name == Name;

    // Wrong on purpose: inherited from object, so it is identity based
    // and two equal Tags get two different numbers.
}

// Output:
// False

A record is the shorthand for all three

Use a record whenever a type is defined by its values rather than its identity, which covers most data carried between layers. Write the three overrides by hand only when the equality rule is unusual, for example when one field must be ignored or compared case-insensitively.

Example · csharp
// A record, added in C# 9, generates ToString, Equals, GetHashCode and ==
// from its positional members. This one line replaces the hand-written class.
sealed record PointRecord(int X, int Y);

// The hand-written equivalent, for comparison
sealed class PointClass(int x, int y)
{
    public int X { get; } = x;
    public int Y { get; } = y;

    public override string ToString() => $"PointClass {{ X = {X}, Y = {Y} }}";

    public override bool Equals(object? obj) =>
        obj is PointClass other && other.X == X && other.Y == Y;

    public override int GetHashCode() => HashCode.Combine(X, Y);
}

// new PointRecord(1, 2).ToString()                  => "PointRecord { X = 1, Y = 2 }"
// new PointRecord(1, 2).Equals(new PointRecord(1, 2)) => True
// new PointRecord(1, 2) == new PointRecord(1, 2)      => True

Overriding Equals does not change ==

For a class, == asks whether two variables point at the same object, and an Equals override has no effect on it. That is why a test written with == can fail while the same test written with Equals passes. Records define == for you; plain classes do not.

Example · csharp
var left = new Code("GB");
var right = new Code("GB");

Console.WriteLine(left.Equals(right));   // value comparison, from the override
Console.WriteLine(left == right);        // reference comparison, still default
Console.WriteLine(ReferenceEquals(left, right));

sealed class Code(string value)
{
    public string Value { get; } = value;

    public override bool Equals(object? obj) => obj is Code other && other.Value == Value;

    public override int GetHashCode() => Value.GetHashCode();
}

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

Discussion

  • Be the first to comment on this lesson.