When i first started learning OOP there is a time I was working on a small inventory system. Nothing fancy, just a Cart class holding a list of Product structs (yes, structs, that decision matters later) and a method that applied a discount to "a copy" of the cart before showing the customer a preview.
Except the preview kept changing the real cart. Every time. I'd apply a 10% discount to the "preview copy," and the original cart's prices would drop too.
I didn't understand what "copy" meant in C#. I assumed everything behaved the same way when assigned or passed around. It doesn't. And once I actually sat down and learned the difference between value types and reference types, that bug, suddenly made sense.
Two Different Ways C# Stores Data
In C#, every type falls into one of two camps:
Value types:
int,double,bool,char,struct,enumReference types:
class,string,array,interface,delegate
The core difference comes down to where the actual data lives and what a variable actually holds.
A value type variable holds its data directly. If you have int x = 5;, that 5 lives right there, wherever x lives, usually the stack.
A reference type variable doesn't hold the object itself. It holds a reference, essentially an address, pointing to where the real object lives on the heap.
struct Point // value type
{
public int X;
public int Y;
}
class Product // reference type
{
public string Name;
public decimal Price;
}
When you create a Point, the X and Y values exist wherever that Point lives. When you create a Product, the variable just holds a pointer to a Product object sitting somewhere else in memory.
What Actually Happens on Assignment
This is where my cart bug lived.
Value types: assignment copies the data
Point a = new Point { X = 1, Y = 2 };
Point b = a; // b gets its OWN copy of the data
b.X = 99;
Console.WriteLine(a.X); // 1 (untouched)
Console.WriteLine(b.X); // 99
b = a doesn't make b point to the same Point. It duplicates the values into a brand new, independent chunk of memory. Changing b afterward has zero effect on a.
Reference types: assignment copies the reference, not the object
Product p1 = new Product { Name = "Mug", Price = 10m };
Product p2 = p1; // p2 gets a COPY OF THE ADDRESS, not a new Product
p2.Price = 999m;
Console.WriteLine(p1.Price); // 999 (also changed!)
Console.WriteLine(p2.Price); // 999
p1 and p2 are two different variables, but they both point to the same object on the heap. Changing the object through p2 is indistinguishable from changing it through p1, because there's only ever been one object the whole time.
What Happens When You Pass Them to Methods
Same rule applies, because passing a parameter is really just another assignment, into the method's local parameter variable.
void TryToDouble(int number)
{
number *= 2;
}
int myNumber = 5;
TryToDouble(myNumber);
Console.WriteLine(myNumber); // still 5
The method got a copy of 5. Doubling the copy does nothing to the original.
void ApplyDiscount(Product product)
{
product.Price *= 0.9m;
}
Product mug = new Product { Name = "Mug", Price = 10m };
ApplyDiscount(mug);
Console.WriteLine(mug.Price); // 9.0 (changed!)
The method received a copy of the reference, but that reference still points at the one and only Product object. So mutating it through the parameter mutates the real thing.
A quick but important nuance: if inside ApplyDiscount you wrote product = new Product { ... }, that would only reassign the local parameter to point at a new object, it wouldn't affect what mug points to back in the caller. Reassignment vs. mutation is a distinction that trips people up constantly. If you actually want a method to reassign the caller's reference variable itself, you need ref:
void Replace(ref Product product)
{
product = new Product { Name = "Replacement", Price = 0m };
}"But Isn't It All Just Stack vs Heap?"
Sort of, because it's the explanation that made me think I understood this before I actually did.
The common shorthand is "value types live on the stack, reference types live on the heap." It's a useful, but it's not strictly accurate:
A value type that's a field inside a class lives on the heap, because it's embedded inside that heap allocated object.
A value type captured in a closure or used with
async/iterators can end up heap allocated too, because the compiler generates a hidden class to hold it.
If you only remember one thing: assigning or passing a value type duplicates the data, while assigning or passing a reference type duplicates the pointer to shared data.
Everything else (the stack/heap details, the ref/out keywords, records, in parameters) is just refinement on top of that one idea.

Join the conversation! Your thoughts help the community grow.