Collections and Generics
Before generics (.NET 1.x), collections stored everything as object:
🎯 What you'll learn
- Why Generics Exist
- The Core Collections
- The Interface Hierarchy
- Writing Generic Types and Methods
- Constraints
1Why Generics Exist
Before generics (.NET 1.x), collections stored everything as object:
var list = new System.Collections.ArrayList();
list.Add(42); // int boxed into an object on the heap
list.Add("oops"); // compiles fine — no type safety
int x = (int)list[0]; // cast + unbox on every readGenerics let a type or method be parameterized by another type, checked at compile time:
var list = new List<int>();
list.Add(42);
// list.Add("oops"); // compile error
int x = list[0]; // no cast, no unboxingTwo wins at once: type safety (wrong types don't compile) and performance (no boxing for value types — see Under the Hood).
2The Core Collections
| Type | Shape | Lookup | Insert | Notes |
|---|---|---|---|---|
List<T> | dynamic array | O(1) by index | O(1) amortized at end, O(n) in middle | default choice for ordered data |
Dictionary<TKey,TValue> | hash table | O(1) avg by key | O(1) avg | key needs good GetHashCode/Equals |
HashSet<T> | hash table, keys only | O(1) avg Contains | O(1) avg | uniqueness, set algebra |
Queue<T> | circular buffer | — | O(1) Enqueue/Dequeue | FIFO |
Stack<T> | array | — | O(1) Push/Pop | LIFO |
LinkedList<T> | doubly linked | O(n) | O(1) given a node | rarely the right answer |
SortedDictionary<TKey,TValue> | red-black tree | O(log n) | O(log n) | keys iterate in order |
var ages = new Dictionary<string, int> { ["ana"] = 31, ["bo"] = 27 };
if (ages.TryGetValue("ana", out int age)) // one lookup, no exception
Console.WriteLine(age);
ages["cy"] = 40; // insert or overwrite
// ages.Add("cy", 41); // throws: key already exists
var seen = new HashSet<string>();
bool isNew = seen.Add("x"); // true first time, false on duplicate3The Interface Hierarchy
IEnumerable<T> // can be iterated (foreach, LINQ)
└─ IReadOnlyCollection<T> // + Count
└─ IReadOnlyList<T> // + indexer (read-only)
└─ ICollection<T> // + Add/Remove/Clear/Count
└─ IList<T> // + indexer, Insert, RemoveAtAccept the narrowest interface you need, return the most useful honest one:
// accepts arrays, lists, sets, LINQ queries...
static int Total(IEnumerable<int> values) => values.Sum();
// callers can index and count, but can't mutate your internal list
public IReadOnlyList<Order> Orders => _orders;4Writing Generic Types and Methods
public class Box<T>
{
public T Value { get; }
public Box(T value) => Value = value;
}
public static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
Max(3, 7); // T inferred as int
Max("a", "b"); // T inferred as string5Constraints
| Constraint | Meaning |
|---|---|
where T : class | reference type |
where T : struct | non-nullable value type |
where T : new() | has a public parameterless constructor |
where T : SomeBase | derives from SomeBase |
where T : IFoo | implements IFoo |
where T : notnull | non-nullable (value or reference) |
Constraints are what let you call members on T — without IComparable<T> above, a.CompareTo(b) wouldn't compile, because the compiler only knows T is "some object."
6Variance: out and in
IEnumerable<string> names = new List<string> { "a" };
IEnumerable<object> objs = names; // OK: IEnumerable<out T> is covariant
Action<object> printAny = o => Console.WriteLine(o);
Action<string> printStr = printAny; // OK: Action<in T> is contravariant
List<object> bad = new List<string>(); // compile error: List<T> is invariantList<T> must be invariant: if a List<string> could be treated as List<object>, you could Add(42) into a list of strings. IEnumerable<T> only produces T (out), so widening it is safe.
Under the HoodReified Generics and Code Sharing▾
Unlike Java's type erasure, .NET generics are reified: List<int> and List<string> are distinct runtime types, and typeof(T) works inside generic code.
The JIT compiles generic code differently depending on the type argument:
- Value types get their own specialized native code per type.
List<int>stores a rawint[]— 4 bytes per element, no boxing, no pointer chasing.List<double>gets separate machine code with 8-byte slots. - Reference types share one copy of native code (compiled for
__Canon), since every reference is the same pointer size. A hidden "generic dictionary" passes the exact type when it's needed (e.g. fortypeof(T)ornew T()).
Dictionary<TKey,TValue> itself is an array of int buckets plus an array of entries (hashCode, next, key, value). A key's hash picks a bucket; the bucket stores the index of the first entry, and collisions chain through next. When Count exceeds capacity the table resizes to the next prime and every entry is re-bucketed — which is why passing a capacity (new Dictionary<,>(10_000)) avoids repeated rehashing when you know the size up front.
⚠️ Common Pitfalls
- Mutating a collection while iterating it —
foreachover aList<T>then callingRemovethrowsInvalidOperationException("Collection was modified"). Iterate a copy, useRemoveAll, or loop backwards with an index. - Mutable keys in a
Dictionary/HashSet. Changing a field that participates inGetHashCodeafter insertion strands the entry in the wrong bucket — it becomes unfindable. - Overriding
EqualswithoutGetHashCode(or vice versa). Hash collections callGetHashCodefirst; inconsistent pairs break lookups. Records generate both correctly. dict[key]for a key that may be missing — throwsKeyNotFoundException. UseTryGetValue.- Exposing
List<T>publicly — callers can mutate your internal state. ExposeIReadOnlyList<T>instead. - Using
List<T>.Containsin a loop — O(n) each time, O(n²) overall. Build aHashSet<T>once.
📋 Quick Reference
| Need | Use |
|---|---|
| ordered, indexed | List<T> |
| key → value | Dictionary<TKey,TValue> |
| uniqueness / membership | HashSet<T> |
| FIFO / LIFO | Queue<T> / Stack<T> |
| sorted by key | SortedDictionary<TKey,TValue> |
| expose read-only | IReadOnlyList<T> / IReadOnlyDictionary<TKey,TValue> |
| safe lookup | TryGetValue |
| generic method | T Foo<T>(T x) where T : ... |
🎤 Interview Q&A
Q1Which collections do you use most and when?core▾
List<T> (default ordered), Dictionary<TKey,TValue> (O(1) lookups), HashSet<T> (uniqueness), Queue<T>/Stack<T>, SortedDictionary, LinkedList rarely, plus ImmutableArray/FrozenDictionary for read-mostly data.
- FrozenDictionary? .NET 8 read-optimized dictionary for data built once.
Q2IEnumerable vs ICollection vs IList vs IReadOnlyList?core▾
IEnumerable: iterate only (possibly lazy). ICollection: count, add/remove. IList: index access. IReadOnlyList/IReadOnlyCollection: read-only views. Accept the least specific type you need; return specific types.
- Why return IReadOnlyList? Communicates immutability to callers.
Q3How do generic constraints work in C#?core▾
where T : class, struct, new(), notnull, unmanaged, base class or interface constraints, allowing generic code to use members safely.
- Generic math?
where T : INumber<T>.
Q4What are concurrent collections?senior▾
ConcurrentDictionary (atomic GetOrAdd/AddOrUpdate), ConcurrentQueue, BlockingCollection, and Channel<T> for producer/consumer pipelines with async support.
- GetOrAdd caveat? The value factory may run more than once.
Q5What are Span<T> and Memory<T>?core▾
Views over contiguous memory (arrays, stack, native) without copying. Span<T> is a stack-only ref struct for synchronous high-performance code; Memory<T> can be stored and used across awaits.
- Use case? Parsing without allocating substrings.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.