C# Fundamentals & Types
The opening questions of almost every C# interview. They look basic, but the follow-ups —
boxing, string allocations, == vs Equals — are where people come
unstuck.
Value types vs reference types
A value type variable holds the data itself; a reference type variable holds a pointer to the data.
| Value type | Reference type | |
|---|---|---|
| Examples | int, bool, double, DateTime, enum, any struct | class, string, arrays, delegate, object, interfaces |
| Assignment copies | The whole value | The reference only |
| Default value | 0 / false — never null | null |
| Can be null? | Only as int? | Yes |
| Usually stored | Stack, or inline in its owner | Heap, managed by the GC |
That's the textbook answer and it's not always true. A value type field inside a class lives on the heap with that object, and captured locals move to the heap too. The reliable distinction is copy semantics, not storage location. Saying that marks you out immediately.
A value type variable contains the data, so assigning it copies the whole value; a
reference type variable contains a reference, so assigning it copies only the pointer and
both variables see the same object. Structs, primitives and enums are value types; classes,
strings, arrays and delegates are reference types. People usually say stack versus heap, but
that's an implementation detail — a struct field inside a class lives on the heap. The real
difference is copy semantics and nullability.
What is boxing and unboxing?
Boxing wraps a value type in an object on the heap; unboxing casts it back. Both cost an allocation and a copy.
int number = 42;
object boxed = number; // BOXING → allocates on the heap, copies the 42 in
int unboxed = (int)boxed; // UNBOXING → checks the type, copies the value back
// the cast must be the EXACT type
long wrong = (long)boxed; // InvalidCastException at runtime
Why it matters: in a tight loop boxing creates millions of short-lived objects and hammers the garbage collector. Generics exist largely to avoid it:
ArrayList list = new ArrayList();
list.Add(42); // boxes every single int
List<int> typed = new List<int>();
typed.Add(42); // no boxing — the list really holds ints
int i = 5;
object o = i; // boxed
i = 10;
Console.WriteLine(i);
Console.WriteLine(o);
Boxing copies the value. Once o holds its own copy of 5,
changing i has no effect on it — the box is a snapshot, not a link.
Boxing converts a value type to a reference type: the runtime allocates an object on the
heap and copies the value into it. Unboxing casts it back, and it must be the exact original
type or you get an InvalidCastException. Both cost an allocation and a copy, so in hot paths
they create GC pressure — which is exactly why generics were introduced. Common hidden
sources are non-generic collections,
string.Format with value types, and
passing a struct where an interface is expected.
Class vs struct
A class is a reference type that supports inheritance; a struct is a value type that is copied on assignment and cannot be inherited.
| class | struct | |
|---|---|---|
| Type | Reference | Value |
| Assignment | Copies the reference | Copies the whole value |
| Inheritance | Yes | No (can implement interfaces) |
| Can be null | Yes | No (unless MyStruct?) |
| Default equality | Reference | Value (field by field) |
| Passing to a method | Cheap (pointer) | Copies every field |
Microsoft's guidance: only when it is small (≈16 bytes or less),
logically a single value, immutable and
short-lived. Point, DateTime and
decimal qualify. A business entity does not — a big mutable struct copied
everywhere is slower than a class and causes subtle bugs.
var points = new List<Point> { new Point(1, 1) };
points[0].X = 99; // compiler error — you'd be mutating a copy, not the list item
This is why mutable structs are considered a design smell.
A class is a reference type — assignment copies the reference, it supports inheritance, it
can be null, and equality is by reference by default. A struct is a value type — assignment
copies every field, it can't inherit, it can't be null unless you make it nullable, and
equality is field by field. I use a struct only for small, immutable, single-value concepts
like a coordinate or a money amount; for anything with identity or behaviour I use a class,
because copying large structs around costs more than passing a reference.
const is compile-time: the value is baked into every assembly that uses it. readonly is set at runtime, in the declaration or the constructor, and can differ per instance. static readonly is one value for the whole type, set once.
public const int MaxRetries = 3; // compile-time, implicitly static, primitives only
public readonly DateTime CreatedAt; // per instance, set in the ctor
public static readonly HttpClient Client = new(); // one per type, any type allowed
Trap: if a library exposes a const and you change it, callers keep
the old value until they are recompiled. For anything public that might change, use
static readonly.
ref vs out vs in
All three pass by reference. ref must be initialised before the call, out must be assigned inside the method, and in is read-only.
void Double(ref int x) { x *= 2; } // caller must initialise first
void Parse(out int y) { y = 42; } // method MUST assign before returning
void Read(in BigStruct s){ /* s is read-only */ } // pass by ref, but can't modify
int a = 5; Double(ref a); // a == 10
int b; Parse(out b); // b == 42 (b did not need a value first)
Parse(out int c); // inline declaration (C# 7)
| Initialised by caller? | Must be assigned in method? | Can method modify? | |
|---|---|---|---|
ref | ✅ Yes | No | ✅ Yes |
out | ❌ No | ✅ Yes | ✅ Yes |
in | ✅ Yes | — | ❌ No |
out is the classic "try" pattern — int.TryParse(s, out var n) — which
returns success plus a value without throwing. in exists for performance: it
passes a large struct by reference so it isn't copied, while guaranteeing the method won't
change it.
All three pass by reference rather than by value.
ref is two-way, so the
caller must initialise the variable first. out is one-way out — the caller
doesn't need to initialise it but the method must assign it before returning, which is how
TryParse works. in passes by reference but read-only, mainly to
avoid copying a large struct. All three need the keyword at the call site as well, which
makes the intent visible.
int? is shorthand for Nullable<int> — a struct wrapping a value
plus a HasValue flag, so a value type can represent "no value" (a NULL column, an
unanswered field).
int? age = null;
int? score = 42;
age.HasValue; // → false
score.HasValue; // → true
score.Value; // → 42
age.Value; // → 💥 InvalidOperationException
age ?? 0; // → 0 the usual safe read
score ?? 0; // → 42
age.GetValueOrDefault(); // → 0
age.GetValueOrDefault(18); // → 18
age + 5; // → null ⚠ null propagates through arithmetic
score + 5; // → 47
age > 10; // → false ⚠ and age <= 10 is ALSO false
Trap: reading .Value when it is null throws
InvalidOperationException, and unboxing a boxed null to int throws too.
A C# 8 compile-time feature. With <Nullable>enable</Nullable>,
string means "shouldn't be null" and string? means "can be null", and
the compiler warns when you dereference something that might be null.
string name = null; // ⚠ warning CS8600
string? maybe = null; // fine
int len = maybe!.Length; // ! = null-forgiving: "trust me" (use sparingly)
It changes nothing at runtime — it's static analysis to catch NullReferenceExceptions at build time instead of in production.
The null-handling operators — they replace piles of null checks.
string? input = null;
var person = new Person { Name = "Ajay", Address = null };
List<int>? list = null;
input ?? "Unknown"; // → "Unknown" null-coalescing
"Neha" ?? "Unknown"; // → "Neha"
"" ?? "Unknown"; // → "" ⚠ empty string is NOT null
person?.Name; // → "Ajay"
person?.Address?.City; // → null Address is null, City never evaluated
person.Address.City; // → 💥 NullReferenceException
list?[0]; // → null no exception
list?.Count; // → null (not 0!)
list?.Count ?? 0; // → 0 the usual combination
person?.Save(); // → does nothing at all if person is null
string? cache = null;
cache ??= "default"; // → cache is now "default"
cache ??= "other"; // → still "default" (only assigns when null)
Chains short-circuit: if Address is null, City is
never evaluated and the whole expression is null.
String immutability
A string can never be changed after it is created — every "modification" allocates a brand new string.
string s = "Hello";
string t = s; // t points at the SAME "Hello"
s += " World"; // does NOT edit "Hello" — it creates a NEW string
// and repoints s at it
Console.WriteLine(s); // → "Hello World"
Console.WriteLine(t); // → "Hello" ⚠ t never changed
s.ToUpper(); // → returns "HELLO WORLD" and THROWS IT AWAY
Console.WriteLine(s); // → "Hello World" still unchanged
s = s.ToUpper(); // you must assign the result back
Console.WriteLine(s); // → "HELLO WORLD"
Why the language does this:
- Thread safety — a string can be shared across threads with no locking.
- Interning — identical literals can safely share one instance.
- Safe as a key — a Dictionary key can't change its hash behind your back.
string result = "";
foreach (var row in rows) // 10,000 rows
result += row + ","; // 10,000 allocations, each copying everything so far
This is the single most common performance bug in beginner C#. The fix
is StringBuilder (Q10).
string a = "hello";
string b = "hello";
string c = new string("hello".ToCharArray());
Console.WriteLine(a == b);
Console.WriteLine(ReferenceEquals(a, b));
Console.WriteLine(a == c);
Console.WriteLine(ReferenceEquals(a, c));
a and b are the same literal, so the compiler
interns them into one shared instance — hence even
ReferenceEquals is true. c is built at runtime, so it is a
different object; == still compares the text and returns true, while
ReferenceEquals reveals they are different objects.
Strings are immutable, so any operation that looks like a modification actually allocates a
new string and leaves the old one for the garbage collector. That gives thread safety, safe
use as dictionary keys, and interning of literals. The practical consequence is that
concatenating in a loop is quadratic — each += copies everything built so far — so for
anything more than a few concatenations I use StringBuilder.
string vs StringBuilder
Use string for a few concatenations; use StringBuilder when you build text in a loop, because it mutates one internal buffer.
var rows = new[] { "Panel", "Cable", "Fuse" };
// ❌ string — a new string on every iteration
string result = "";
foreach (var row in rows)
result += row + ",";
// allocations: "Panel," → "Panel,Cable," → "Panel,Cable,Fuse,"
// → "Panel,Cable,Fuse," 3 rows = 3 allocations; 10,000 rows = 10,000
// ✅ StringBuilder — one buffer, one string at the end
var sb = new StringBuilder();
foreach (var row in rows)
sb.Append(row).Append(',');
string result2 = sb.ToString();
// → "Panel,Cable,Fuse," one allocation, whatever the row count
// ✅ better still when you just need a separator
string result3 = string.Join(",", rows);
// → "Panel,Cable,Fuse" no trailing comma to trim
| Use… | When |
|---|---|
string / interpolation | A known, small number of pieces: $"{first} {last}" |
string.Join | Joining a collection with a separator — clearer and fast |
StringBuilder | Loops, unknown counts, building reports/CSV/SQL |
"a" + "b" + "c" in one statement is not slow — the compiler folds
literals and turns runtime concatenation into a single string.Concat call. The
problem is specifically repeated concatenation across iterations. Knowing
that distinction shows you understand the why, not just the rule.
== vs Equals()
== is a static operator chosen at compile time;
Equals() is a virtual method resolved at runtime.
== | .Equals() | |
|---|---|---|
| Kind | Static operator, can be overloaded | Virtual method, can be overridden |
| Resolved | By the compile-time type | By the runtime type |
| Default for classes | Reference comparison | Reference comparison |
For string | Compares text (overloaded) | Compares text |
| Null safety | Handles null | null.Equals(x) throws |
object a = "hello";
object b = "hel" + GetLo(); // built at runtime → not interned
Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(((string)a) == ((string)b));
static string GetLo() => "lo";
Both variables are declared as object, so == binds to
object's reference comparison at compile time — and these are two
different instances, so it's False. Equals is virtual, so it
dispatches to string.Equals and compares the text. Cast to string
and == picks the string overload, so it's true again.
The rule: == follows the declared type,
Equals follows the actual type.
- Strings:
==is fine and readable. For case-insensitive usestring.Equals(a, b, StringComparison.OrdinalIgnoreCase)— neverToLower() ==, which allocates. - Possibly-null values:
object.Equals(a, b)ora?.Equals(b) ?? b is null. - Identity:
ReferenceEquals(a, b)— unambiguous.
== is a static operator resolved at compile time from the declared type, so
it can be overloaded but not overridden; Equals is a virtual method resolved at
runtime from the actual type. For strings both compare the text, because string overloads
==. The classic gotcha is assigning strings to object variables —
then == compares references and can be false while Equals is true.
For reference types the default for both is reference equality unless the type overrides
them, which records do automatically.
GetHashCode() — and why it matters
It returns the number a hash-based collection uses to pick a bucket. If you override
Equals you must override GetHashCode.
The contract:
- Equal objects must return the same hash code.
- Different objects may share one (that's a collision — Q110).
- The hash must not change while the object is in a collection.
public override bool Equals(object? obj) =>
obj is Money m && m.Amount == Amount && m.Currency == Currency;
public override int GetHashCode() =>
HashCode.Combine(Amount, Currency); // built-in, correct, fast
Add an object to a Dictionary or HashSet, then change a field the
hash is built from: the object is now in the wrong bucket. Lookups fail, and it may
even show up when enumerating while being unfindable. Hash only immutable fields —
which is one reason records and value objects are immutable.
GetHashCode gives hash-based collections a number to choose a bucket, and
Equals then distinguishes items inside that bucket. The contract is that equal
objects must return the same hash, so overriding Equals without overriding
GetHashCode silently breaks Dictionary and HashSet. I
use HashCode.Combine and only include immutable fields — if a hashed field
changes while the object is in a collection, it ends up in the wrong bucket and can never be
found again. Records generate both correctly for you.