A · C# Language → Module 01
Module 01 · Questions 1–12

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.

Q1

Value types vs reference types

In one line

A value type variable holds the data itself; a reference type variable holds a pointer to the data.

VALUE TYPE REFERENCE TYPE int a = 5; var p1 = new Person { Age = 5 }; int b = a; var p2 = p1; b = 10; p2.Age = 10; a = 5 b = 10 p1.Age = 10 p2.Age = 10 (two separate copies) (one object, two references)
Value typeReference type
Examplesint, bool, double, DateTime, enum, any structclass, string, arrays, delegate, object, interfaces
Assignment copiesThe whole valueThe reference only
Default value0 / false — never nullnull
Can be null?Only as int?Yes
Usually storedStack, or inline in its ownerHeap, managed by the GC
Trap — "value types live on the stack"

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.

30-second interview answer

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.

Q2

What is boxing and unboxing?

In one line

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
STACK HEAP int number = 42 ┌──────────────┐ object boxed ─────► │ type: int │ │ value: 42 │ └──────────────┘ an allocation the GC must later collect

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
Predict the output
int i = 5;
object o = i;      // boxed
i = 10;

Console.WriteLine(i);
Console.WriteLine(o);
10 5

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.

30-second interview answer

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.

Q3

Class vs struct

In one line

A class is a reference type that supports inheritance; a struct is a value type that is copied on assignment and cannot be inherited.

classstruct
TypeReferenceValue
AssignmentCopies the referenceCopies the whole value
InheritanceYesNo (can implement interfaces)
Can be nullYesNo (unless MyStruct?)
Default equalityReferenceValue (field by field)
Passing to a methodCheap (pointer)Copies every field
When to actually choose a struct

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.

Trap — the mutable struct in a list
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.

30-second interview answer

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.

Q4const vs readonly vs static readonly

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.

Q5

ref vs out vs in

In one line

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✅ YesNo✅ 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.

30-second interview answer

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.

Q6What is a nullable value type?

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.

Q7What is a nullable reference type?

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.

Q8What do ??, ??=, ?. and ?[ ] do?

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.

Q9

String immutability

In one line

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"
before: s ──► "Hello" ◄── t after s += " World": s ──► "Hello World" (a brand new object) t ──► "Hello" (untouched — this is why it's safe to share)

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.
Trap — the O(n²) loop
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).

Predict the output
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));
True True True False

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.

30-second interview answer

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.

Q10

string vs StringBuilder

In one line

Use string for a few concatenations; use StringBuilder when you build text in a loop, because it mutates one internal buffer.

string concatenation in a loop StringBuilder "a" ┌───────────────┐ "ab" ← new allocation │ a b c d ... │ one buffer, "abc" ← new allocation └───────────────┘ grown occasionally "abcd" ← new allocation n allocations, O(n²) copying ~log n reallocations, O(n)
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 / interpolationA known, small number of pieces: $"{first} {last}"
string.JoinJoining a collection with a separator — clearer and fast
StringBuilderLoops, unknown counts, building reports/CSV/SQL
Nuance worth adding

"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.

Q11

== vs Equals()

In one line

== is a static operator chosen at compile time; Equals() is a virtual method resolved at runtime.

==.Equals()
KindStatic operator, can be overloadedVirtual method, can be overridden
ResolvedBy the compile-time typeBy the runtime type
Default for classesReference comparisonReference comparison
For stringCompares text (overloaded)Compares text
Null safetyHandles nullnull.Equals(x) throws
Predict the output — this is the classic trick question
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";
False True True

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.

What to use in practice
  • Strings: == is fine and readable. For case-insensitive use string.Equals(a, b, StringComparison.OrdinalIgnoreCase) — never ToLower() ==, which allocates.
  • Possibly-null values: object.Equals(a, b) or a?.Equals(b) ?? b is null.
  • Identity: ReferenceEquals(a, b) — unambiguous.
30-second interview answer

== 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.

Q12

GetHashCode() — and why it matters

In one line

It returns the number a hash-based collection uses to pick a bucket. If you override Equals you must override GetHashCode.

dictionary.Add(key, value) │ ├─ GetHashCode() → 8842 → bucket 12 │ └─ inside bucket 12, use Equals() to find/compare the exact key Two "equal" objects with different hash codes land in different buckets → the dictionary can never find the second one.

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
Trap — the mutable key

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.

30-second interview answer

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.