📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Every C# type derives from System.Object. What members does it give you? Then review a Customer class that overrides Equals to compare Email but does not override GetHashCode: what breaks, and how do you implement equality correctly (or use a record)?

System.Object's members and contracts, reviewed through a broken Equals override: the Equals/GetHashCode contract, IEquatable<T>, operators, inheritance pitfalls, records, ToString and MemberwiseClone.

Mid-Level CodeReview Intermediate csharp equality gethashcode object records types

🎯 Key points

  • System.Object provides Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, and static ReferenceEquals and Equals(a, b)
  • Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(); unequal objects may share a hash
  • Overriding Equals without GetHashCode breaks Dictionary, HashSet, Distinct, GroupBy and EF Core change tracking
  • Hash codes must not change while an object is in a hashed collection: base them on immutable fields
  • Equals must be reflexive, symmetric, transitive, consistent and return false for null
  • Implement IEquatable<T>, override Equals(object) and GetHashCode with HashCode.Combine, and overload == and != consistently
  • Inheritance breaks symmetry unless you compare exact types or seal the class; records handle this with EqualityContract
  • Prefer records for value-like data; entities usually keep identity-based equality by Id

⏱ 30-second answer

System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.

🗣 Full answer (senior)

How I would answer this in an interview

I start with what System.Object provides, then go straight to the bug: overriding Equals without GetHashCode breaks the equality contract, so hash-based collections (Dictionary, HashSet, LINQ Distinct, GroupBy) silently misbehave. Then I show a correct implementation and when a record is the better answer.

1. What System.Object gives every type

Member Default behaviour Override?
Equals(object?) Reference equality for classes; field-wise for structs (ValueType) When the type has value semantics
GetHashCode() Identity-based for classes (stable per object); field-based for structs Always together with Equals
ToString() Fully qualified type name For logging/debugging; don't put sensitive data in it
GetType() Runtime type (non-virtual) Cannot override
MemberwiseClone() (protected) Shallow copy Used to implement cloning
Finalize() (protected) Nothing Only for unmanaged resources (via ~Type()), very rarely
static Equals(a, b), ReferenceEquals(a, b) Null-safe equality / pure reference check —

2. The code under review

public class Customer
{
    public string Email { get; set; } = "";
    public string Name { get; set; } = "";

    public override bool Equals(object? obj) => obj is Customer c && c.Email == Email;
    // GetHashCode NOT overridden (compiler warning CS0659)
}

3. What breaks

The contract: if a.Equals(b) is true, then a.GetHashCode() == b.GetHashCode() must be true. Here two customers with the same email are "equal" but have different identity-based hash codes.

  • HashSet<Customer> / Dictionary<Customer, ...>: lookups go to the bucket chosen by the hash code, so an "equal" customer is not found, and duplicates are inserted.
  • LINQ Distinct(), GroupBy(), Union(), ToDictionary() use hashing: duplicates survive or groups split.
  • Contains on a List<T> works (it uses Equals only), so tests that use lists pass while production code using sets fails — the bug hides.

Further problems in this code:

  • Mutable key: Email has a public setter. Even with a correct GetHashCode, changing the email of an object already inside a HashSet or used as a dictionary key strands it in the wrong bucket.
  • Case sensitivity: "[email protected]" and "[email protected]" are the same mailbox domain-wise but not equal here; it should use StringComparison.OrdinalIgnoreCase (and the same comparer in the hash).
  • No IEquatable<Customer>: generic collections call Equals(object), which needs a type check per call.
  • No == overload: a == b still compares references, so Equals and == disagree — confusing.
  • Inheritance: obj is Customer means a VipCustomer can equal a Customer but not necessarily vice versa if VipCustomer overrides — symmetry breaks. Compare GetType() or seal the class.
  • Entity vs value: is a customer even a value? Usually customers are entities with an identity (CustomerId); two customers can share an email over time. Equality by email may be the wrong model.

4. A correct implementation

public sealed class Customer : IEquatable<Customer>
{
    public Customer(string email, string name) { Email = email; Name = name; }
    public string Email { get; }              // immutable key
    public string Name { get; set; }

    public bool Equals(Customer? other) =>
        other is not null &&
        (ReferenceEquals(this, other) || string.Equals(Email, other.Email, StringComparison.OrdinalIgnoreCase));

    public override bool Equals(object? obj) => Equals(obj as Customer);
    public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Email);
    public static bool operator ==(Customer? a, Customer? b) => a?.Equals(b) ?? b is null;
    public static bool operator !=(Customer? a, Customer? b) => !(a == b);
}

5. When to use a record instead

If the type is data with value semantics (DTOs, value objects like EmailAddress, Money, Address), a record generates Equals, GetHashCode, ==, !=, IEquatable<T>, ToString and an EqualityContract that handles inheritance correctly:

public sealed record EmailAddress
{
    public EmailAddress(string value) => Value = value.Trim().ToLowerInvariant();
    public string Value { get; }
}

Caveats: records compare all fields (often not what you want for entities), and collection-typed members compare by reference (List<T> fields make two records unequal even with the same items).

For entities (EF Core), I usually do not override equality at all, or compare by ID only once the ID is assigned — EF Core relies on reference identity in its change tracker.

6. A production example

A de-duplication step in a marketing export used customers.Distinct() on a class that overrode Equals by email but not GetHashCode. Unit tests used List.Contains and passed. In production, the export contained duplicates and some customers received the same email three times — a complaint risk and a Spam Act 2003 concern. The fix was a proper IEquatable implementation with a case-insensitive hash, plus a test that put two equal instances into a HashSet<Customer> and asserted a count of one. We also enabled CS0659 as an error so the compiler blocks this mistake.

🔍 What the interviewer is checking

A classic code-review question that reveals whether the candidate understands how hashed collections work.

Green flags

  • Names the missing GetHashCode immediately and explains buckets.
  • Mentions immutability of key fields, symmetry with inheritance, and == consistency.
  • Uses IEquatable<T>, HashCode.Combine or records.
  • Distinguishes entity identity from value equality.

Red flags

  • Thinks GetHashCode must be unique.
  • Returns a constant hash code to "fix" it (correct but turns every lookup into O(n)).
  • Unaware of what records generate.

📚 Learn in depth

1. The equality contract in full

Equals must be:

  1. Reflexive: x.Equals(x) is true.
  2. Symmetric: x.Equals(y) == y.Equals(x).
  3. Transitive: if x.Equals(y) and y.Equals(z) then x.Equals(z).
  4. Consistent: repeated calls return the same result while the objects do not change.
  5. Null-safe: x.Equals(null) is false and never throws.

GetHashCode must:

  • Return equal values for equal objects (the hard requirement).
  • Stay stable while the object is in a hash-based collection.
  • Ideally spread values uniformly across int to minimise collisions.
  • Not be persisted or sent across processes — string hash codes are randomised per process in .NET Core (to resist hash-flooding attacks), so "abc".GetHashCode() differs between runs.

Unequal objects may share a hash code (collisions are allowed); equal objects must share one.

2. How Dictionary<TKey,TValue> uses them

  1. Compute hash = comparer.GetHashCode(key).
  2. Pick a bucket: hash % buckets.Length (with fast modulo via multiplication on 64-bit).
  3. Walk the chain of entries in that bucket, comparing stored hash codes first, then calling comparer.Equals(entry.key, key) only when hashes match.

If two equal keys produce different hashes, they land in different buckets and Equals is never called — so overriding Equals alone does nothing for dictionaries. If GetHashCode returns a constant (return 1;), everything lands in one bucket and lookups degrade from O(1) to O(n).

3. Default implementations

  • object.GetHashCode() for classes: an identity hash, generated on first call and stored in the object header (or the sync block if the header is already used by a lock). It never changes for the object's lifetime, regardless of field values.
  • object.Equals: ReferenceEquals(this, obj).
  • ValueType.Equals: field-by-field, using a fast bitwise compare when the struct has no references or padding, otherwise reflection.
  • ValueType.GetHashCode: historically based on the first non-null field; may be poor. Always override for structs used as keys.

4. What records generate

For record Person(string Name, int Age) the compiler emits roughly:

protected virtual Type EqualityContract => typeof(Person);

public virtual bool Equals(Person? other) =>
    (object)this == other ||
    (other is not null && EqualityContract == other.EqualityContract &&
     EqualityComparer<string>.Default.Equals(Name, other.Name) &&
     EqualityComparer<int>.Default.Equals(Age, other.Age));

public override int GetHashCode() =>
    HashCode.Combine(EqualityContract, Name, Age);   // conceptually

EqualityContract ensures a derived record (Employee : Person) is never equal to a base Person with the same values — solving the symmetry problem that hand-written obj is Customer checks have. You can customise by writing your own Equals(Person? other) and GetHashCode in the record (for example to compare a list property with SequenceEqual).

5. HashCode.Combine and good hash functions

HashCode.Combine(a, b, c) (.NET Core 2.1+) uses xxHash32 with a per-process random seed, giving good distribution and resistance to collision attacks. Older patterns like hash = hash * 31 + field are fine but weaker. For strings with custom comparison, use the matching comparer's hash: StringComparer.OrdinalIgnoreCase.GetHashCode(s) — using s.ToLower().GetHashCode() allocates and can disagree with your Equals on culture edge cases.

6. Equality in EF Core and the identity map

EF Core's change tracker uses reference identity and key values to track entities. Overriding Equals/GetHashCode on entities with mutable properties (or with database-generated IDs that change from 0 to a real value after SaveChanges) can break collections of entities in navigation properties: an entity added to a HashSet<OrderLine> with hash based on Id = 0 is "lost" after the ID is assigned. Common guidance:

  • Value objects (owned types, complex types in EF Core 8+): value equality.
  • Entities: reference equality, or identity-based equality that only compares when IDs are assigned, with GetHashCode based on something immutable.

7. ToString and logging

Overriding ToString improves debugging and logs (the debugger shows it in watch windows; [DebuggerDisplay] is a non-invasive alternative). Records generate a ToString printing all public properties — including personal information such as emails or tax file numbers. Under the Privacy Act and APP 11, logging that data needlessly is a risk; override PrintMembers or ToString on records that contain personal data.

Try it yourself

  1. Put two "equal" instances of the broken Customer into a HashSet and print the count.
  2. Add the correct GetHashCode, then mutate Email of an object already in the set and try Contains.
  3. Create record Person and record Employee : Person with the same values and compare them.
  4. Print "abc".GetHashCode() in two separate runs.

💻 Code

namespace Types;

// Correct hand-written equality for a class keyed by email (case-insensitive)
public sealed class Customer : IEquatable<Customer>
{
    public Customer(string email, string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(email);
        Email = email.Trim();
        Name = name;
    }

    public string Email { get; }          // immutable key
    public string Name { get; set; }      // not part of equality

    public bool Equals(Customer? other) =>
        other is not null && StringComparer.OrdinalIgnoreCase.Equals(Email, other.Email);

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

    public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Email);

    public static bool operator ==(Customer? left, Customer? right) =>
        left is null ? right is null : left.Equals(right);

    public static bool operator !=(Customer? left, Customer? right) => !(left == right);

    public override string ToString() => $"Customer({Name})";   // no email in logs
}

// Value-like data: let the compiler generate everything
public sealed record Address(string Street, string Suburb, string State, string Postcode);

public static class EqualityDemo
{
    public static void Main()
    {
        var a = new Customer("[email protected]", "Ada");
        var b = new Customer("[email protected]", "Ada L.");

        var set = new HashSet<Customer> { a, b };
        Console.WriteLine(set.Count);                    // 1
        Console.WriteLine(a == b);                       // True
        Console.WriteLine(ReferenceEquals(a, b));        // False

        var home = new Address("1 George St", "Sydney", "NSW", "2000");
        Console.WriteLine(home == home with { });        // True: value equality
        Console.WriteLine(home);                         // Address { Street = ..., ... }
    }
}

⚠ Common mistakes

1. Overriding Equals without GetHashCode

What happens: the compiler warns (CS0659) and the warning is ignored. Why it is wrong: equal objects get different hash codes, so Dictionary, HashSet, Distinct and GroupBy treat them as different. Do instead: always override both together (and treat CS0659/CS0661 as errors), or use a record.

2. Hash codes based on mutable fields

What happens: GetHashCode uses properties with public setters, and objects are modified while stored in sets or used as keys. Why it is wrong: the object stays in the bucket for its old hash, so it can no longer be found or removed. Do instead: base equality on immutable values; make key properties get-only or init.

3. Inconsistent comparison rules between Equals and GetHashCode

What happens: Equals compares emails case-insensitively but GetHashCode uses Email.GetHashCode(). Why it is wrong: "[email protected]" and "[email protected]" are equal but hash differently. Do instead: use the same comparer for both: StringComparer.OrdinalIgnoreCase.GetHashCode(Email).

4. Using is checks that break symmetry with inheritance

What happens: obj is Customer c in a non-sealed class with derived types that add fields. Why it is wrong: customer.Equals(vip) can be true while vip.Equals(customer) is false. Do instead: seal the class, compare GetType(), or use records (which use EqualityContract).

5. Overriding Equals but not ==

What happens: a.Equals(b) is true but a == b is false. Why it is wrong: developers use both interchangeably; inconsistent results cause subtle bugs. Do instead: overload == and != consistently for value-like types (records do this automatically).

6. Giving entities value equality on all fields

What happens: EF Core entities are made records or compare all properties. Why it is wrong: entities have identity, change over time, and EF's change tracker relies on reference identity; navigation collections break. Do instead: use records for value objects and DTOs, not tracked entities.

7. Assuming records compare collections by content

What happens: record Order(int Id, List<Line> Lines) instances with identical lines compare unequal. Why it is wrong: generated equality uses EqualityComparer<List<Line>>.Default, which is reference equality. Do instead: use ImmutableArray<T> with custom equality, or override Equals to use SequenceEqual.

8. Persisting or sharing GetHashCode values

What happens: using name.GetHashCode() as a cache key in Redis, a partition key, or a database column. Why it is wrong: string hash codes are randomised per process, so the value changes on restart and differs between servers. Do instead: use a stable hash (XxHash64, SHA-256) from System.IO.Hashing or System.Security.Cryptography.

9. Throwing from Equals or GetHashCode

What happens: Equals dereferences obj without a null check, or GetHashCode throws when a field is null. Why it is wrong: collections call these methods implicitly, so exceptions appear from deep inside framework code. Do instead: handle nulls and type mismatches by returning false; use HashCode.Combine, which accepts nulls.

10. Leaking personal data through ToString

What happens: record-generated ToString includes emails, phone numbers or addresses, which end up in logs. Why it is wrong: unnecessary collection and storage of personal information conflicts with the Privacy Act (APP 11) and expands the impact of a data breach. Do instead: override ToString/PrintMembers to exclude sensitive properties, or use structured logging with explicit fields.

🏛 Senior insight: trade-offs

Equality is a domain modelling decision: identity or value.

Kind of type Equality Implementation
Entity (Customer with Id) identity reference or Id only
Value object (Money, Address) all values record / readonly record struct
DTO / message values record
Dictionary key stable values immutable fields + IEquatable<T>
Polymorphic hierarchy rarely value equality avoid, or records with EqualityContract
  • Prefer passing an IEqualityComparer<T> (StringComparer.OrdinalIgnoreCase, custom comparers) to collections instead of baking context-specific equality into the type.
  • HashCode.Combine produces good distribution and is randomised per process, so never persist hash codes.
  • Records compare collection properties by reference; for deep equality write a custom Equals or use immutable collections plus a comparer.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate explains hash buckets. Why: Goes inside Dictionary to show how buckets use GetHashCode and Equals.
    How to answer

    A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).

    .NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.

    Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.

  2. When: The candidate mentions ValueType.Equals. Why: Default struct equality boxes; tests performance awareness.
    How to answer

    Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.

🛠 Real-world problems: how you handle them

Duplicate marketing emails from a broken Equals override
Situation
A retail CRM sent weekly marketing emails to about 200,000 subscribers; the dedup step used Distinct() on a Customer class whose Equals compared Email.
Signals
Complaints about duplicate emails, a Spam Act complaint escalated to the privacy officer, and send counts higher than subscriber counts.
Investigation
Compared send logs with the subscriber table, reproduced in a unit test with two customers of the same email, saw CS0659 in the build output, and confirmed Distinct used identity hashes.
Resolution
Implemented IEquatable<Customer> with a case-insensitive email comparer in both Equals and GetHashCode, made Email immutable, and normalised emails at sign-up.
Prevention
CS0659 treated as an error, unit tests for equality contracts, and a unique index on the normalised email column.

How to tell it (STAR)

**S:** Our CRM [at company] was sending some customers the same campaign twice, and one complaint reached the privacy officer. **T:** I had to find the cause before the next weekly send. **A:** I reproduced it, found Equals was overridden without GetHashCode so Distinct never compared the emails, implemented equality properly with one case-insensitive comparer, and made the warning a build error. **R:** Duplicates stopped from the next send, and the database index now enforces uniqueness too.

↩ Usually asked after

More in Types

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me