Skip to main content
CodeOath
← All posts

C#64 min total · 17 parts

C# Fundamentals: Value Types, Reference Types, Boxing, and the Type System

Part 5 of 17 · ~3 min

Value Equality vs. Reference Equality

== isn't one fixed operation — what it actually checks changes completely depending on which type is sitting on either side of it.

// Value types: == compares the actual data
int a = 5, b = 5;
Console.WriteLine(a == b); // true

// Reference types with no overrides: == compares identity — same object in memory?
var shipA = new Shipment { OrderId = "ORD-9001" };
var shipB = new Shipment { OrderId = "ORD-9001" };
Console.WriteLine(shipA == shipB);      // false — two different objects, same OrderId or not
Console.WriteLine(shipA.Equals(shipB)); // also false — Object.Equals is identity-based by default

// string is the built-in reference type where == is overloaded to mean "same characters"
string skuA = "WDG-100";
string skuB = "WDG-" + "100";
Console.WriteLine(skuA == skuB); // true — string overrides == to compare content, not identity

Shipment never defined its own notion of equality, so == and the inherited Equals both fall back to "are these the exact same object" — two shipments that happen to share an OrderId are still "not equal" as far as C# is concerned, which is actually the correct behavior here: ORD-9001 printed twice by mistake should never be treated as the same shipment just because the id matches. Identity is the right default for something with a real-world identity like an order.

A struct, on the other hand, inherits value-based equality from ValueType automatically — two StockCount values with identical fields compare equal without you writing anything:

var countA = new StockCount { Sku = "WDG-100", OnHand = 42 };
var countB = new StockCount { Sku = "WDG-100", OnHand = 42 };
Console.WriteLine(countA.Equals(countB)); // true — same Sku, same OnHand

Here's the catch, worth knowing before you lean on that inherited comparison inside anything performance-sensitive: ValueType's field-by-field check gets there by walking the struct's fields through reflection, unless you've given it a hand-written Equals to use instead — and reflection-driven comparison carries real, measurable overhead that a couple of hard-coded field comparisons never would. Compare two StockCounts once to decide a dictionary lookup and you'll never notice. Run that same comparison inside a scan loop processing thousands of items a minute and the overhead becomes a genuine, easy-to-overlook cost — which is precisely the gap record struct, a few sections ahead, closes automatically, with generated field comparisons instead of reflection.

If Shipment ever needs value equality — say, deduplicating a batch of shipment requests before they become real orders — overriding Equals is only half the job:

class ShipmentRequest
{
    public string Sku;
    public int Quantity;
    public override bool Equals(object obj) =>
        obj is ShipmentRequest other && Sku == other.Sku && Quantity == other.Quantity;
    public override int GetHashCode() => HashCode.Combine(Sku, Quantity);
}

Leave GetHashCode out and nothing throws to tell you so. A Dictionary or HashSet decides which bucket an item belongs in using GetHashCode, and only reaches for Equals afterward, to confirm the match inside whichever bucket it landed on. Give two logically-identical ShipmentRequests different hash codes and they can end up sorted into two different buckets entirely — at which point a lookup for one will simply never encounter the other, no matter how correct Equals is. There's no crash, no message, nothing in a log. The dictionary just behaves as though the item was never added, which tends to take far longer to track down than any exception would.