Free Handbook · Every example compiled & verified

Collections & Generics

List, Dictionary, HashSet, Queue, Stack and SortedDictionary, then the generics behind them: generic classes and methods, where constraints, yield and IEnumerable.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 08 · what you'll be able to do

  • Store, look up and remove data with List, Dictionary, HashSet, Queue and Stack
  • Write your own generic class and generic method, and let the compiler infer the type argument
  • Add where constraints so a generic method can compare, construct or null-check its T
  • Produce a lazy sequence with yield return and explain when its code actually runs
  • Pick the right collection for a job from its lookup, insert and ordering costs
01

List<T>: the resizable array

An array (Module 03) has a fixed length. List<T> is the collection you reach for when you do not know the size up front: it wraps an array and, when that array fills up, allocates a bigger one (double the size) and copies the items across. The <T> is a type argument — List<int> holds only ints, List<string> only strings — so the compiler checks every Add and you never cast on the way out.

C#Program.cs
var scores = new List<int> { 90, 72, 85 };
scores.Add(64);
scores.Insert(0, 100);          // insert at index 0
scores.Remove(72);              // removes the first 72

Console.WriteLine($"Count: {scores.Count}");
Console.WriteLine($"First: {scores[0]}, last: {scores[^1]}");
Console.WriteLine($"Has 85? {scores.Contains(85)}, index of 85: {scores.IndexOf(85)}");

scores.Sort();
Console.WriteLine(string.Join(", ", scores));

foreach (int s in scores)
{
    if (s >= 85) Console.Write(s + " ");
}
Console.WriteLine();
Outputcompiled & run with real C#
Count: 4
First: 100, last: 64
Has 85? True, index of 85: 2
64, 85, 90, 100
85 90 100
Your turn

Call scores.RemoveAt(0) after sorting and predict the new first element before you run it.

OperationMethodCost
Read or write by indexlist[i]O(1)
Add to the endAdd(x)O(1) on average (occasional resize)
Insert or remove in the middleInsert(i, x), RemoveAt(i)O(n) — every later item shifts
Search by valueContains(x), IndexOf(x)O(n) — a linear scan
SortSort()O(n log n)
Error you will hit

CS1503: adding the wrong type to a List<int>

C#
var scores = new List<int> { 90, 85 };
scores.Add("100");
Program.cs(2,12): error CS1503: Argument 1: cannot convert from 'string' to 'int'
Why the compiler said that

scores is a List<int>, so Add takes an int. A string that happens to contain digits is still a string — C# never converts it for you. This is the whole point of generics: the mistake is caught at compile time, not when some later code tries to do maths with it.

The fix

Pass an int, or parse the string explicitly if it really comes from text input.

C#
var scores = new List<int> { 90, 85 };
scores.Add(100);
scores.Add(int.Parse("100"));
Capacity versus Count
Count is how many items you stored. Capacity is the size of the hidden array. If you know you are about to add 10,000 items, new List<int>(10_000) allocates once instead of resizing about fourteen times on the way up.
02

Dictionary<TKey, TValue>

A Dictionary<TKey, TValue> maps unique keys to values using a hash table: the key's GetHashCode() picks a bucket, so a lookup costs O(1) on average no matter how many entries there are. Use it whenever you would otherwise search a list for "the item whose id is X".

C#Program.cs
var stock = new Dictionary<string, int>
{
    ["apple"] = 12,
    ["pear"] = 0,
};
stock["kiwi"] = 7;          // indexer set: adds or overwrites
stock["apple"] += 3;        // read, add, write back

if (stock.TryGetValue("pear", out int pears))
    Console.WriteLine($"pear: {pears}");

Console.WriteLine($"has mango? {stock.ContainsKey("mango")}");
Console.WriteLine($"mango or 0: {stock.GetValueOrDefault("mango")}");

stock.Remove("pear");
foreach (var (fruit, qty) in stock.OrderBy(p => p.Key))
    Console.WriteLine($"{fruit} = {qty}");
Outputcompiled & run with real C#
pear: 0
has mango? False
mango or 0: 0
apple = 15
kiwi = 7
Your turn

Count how many times each word appears in "to be or not to be".Split(' ') using a Dictionary<string, int> and GetValueOrDefault.

Do not rely on iteration order
A Dictionary makes no promise about the order foreach visits entries. It often looks like insertion order, until you remove something and add again. When order matters, sort on the way out (as above) or use SortedDictionary.
Error you will hit

ArgumentException: Add with a key that already exists

C#
var ages = new Dictionary<string, int>();
ages.Add("ana", 31);
ages.Add("ana", 32);
Unhandled exception. System.ArgumentException: An item with the same key has already been added. Key: ana
   at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
   at Program.<Main>$(String[] args) in Program.cs:line 3
Why the compiler said that

Add means "this key is new" and throws if it is not. The indexer ages["ana"] = 32 means "set this key, whatever was there" and never throws.

The fix

Use the indexer to overwrite, or TryAdd when a duplicate should be ignored rather than crash.

C#
var ages = new Dictionary<string, int>();
ages["ana"] = 31;
ages["ana"] = 32;          // overwrite
bool added = ages.TryAdd("ana", 40); // false, value stays 32
03

HashSet, Queue, Stack and the sorted collections

HashSet<T> is a dictionary with keys only: O(1) "have I seen this?" checks and set maths. Queue<T> is first-in-first-out (a line at a shop); Stack<T> is last-in-first-out (a pile of plates, the undo button). SortedDictionary and SortedSet keep their keys in order at all times, at O(log n) per operation instead of O(1).

C#Program.cs
var seen = new HashSet<string>();
foreach (var tag in new[] { "c#", "linq", "c#", "async" })
{
    bool isNew = seen.Add(tag);          // false when already present
    Console.WriteLine($"{tag}: {(isNew ? "new" : "duplicate")}");
}

var a = new HashSet<int> { 1, 2, 3, 4 };
var b = new HashSet<int> { 3, 4, 5 };
a.IntersectWith(b);
Console.WriteLine("both: " + string.Join(",", a.Order()));

var line = new Queue<string>();
line.Enqueue("ana"); line.Enqueue("ben"); line.Enqueue("cy");
Console.WriteLine($"served {line.Dequeue()}, next is {line.Peek()}");

var undo = new Stack<string>();
undo.Push("type"); undo.Push("bold"); undo.Push("delete");
Console.WriteLine($"undo {undo.Pop()}, then {undo.Pop()}");
Outputcompiled & run with real C#
c#: new
linq: new
c#: duplicate
async: new
both: 3,4
served ana, next is ben
undo delete, then bold
C#Program.cs
var grades = new SortedDictionary<string, int>
{
    ["zoe"] = 88,
    ["ana"] = 95,
    ["mia"] = 71,
};
grades["ben"] = 80;

// always visited in key order, however you inserted them
foreach (var (name, grade) in grades)
    Console.WriteLine($"{name}: {grade}");

var sizes = new SortedSet<int> { 42, 7, 19, 7 };
Console.WriteLine($"min {sizes.Min}, max {sizes.Max}, count {sizes.Count}");
Outputcompiled & run with real C#
ana: 95
ben: 80
mia: 71
zoe: 88
min 7, max 42, count 3
Your turn

Add sizes.GetViewBetween(10, 50) and print it with string.Join.

VisualizeA Stack checking balanced bracketsStep 1 / 6
var open = new Stack<char>();
foreach (char c in "([])")
{
if (c == '(' || c == '[') open.Push(c);
else open.Pop();
}
Console.WriteLine(open.Count == 0);
Line 1

An empty stack of chars.

Variables now
open[]
All 6 steps as a table
StepLineWhat happenedVariables now
11An empty stack of chars.open = []
24( is an opener, so it is pushed.c = '(' open = [(]
34[ is pushed on top of it.c = '[' open = [(, []
45] is a closer: pop removes the most recent opener, the [.c = ']' open = [(]
55) pops the (. The stack is empty again.c = ')' open = []
67Every opener was matched, so the count is 0.
04

Writing generic classes and methods

Every collection above is written once and works for any T. You can do the same. A generic class puts the type parameter after the class name (class Box<T>); a generic method puts it after the method name (Swap<T>). At a call site the compiler usually infers T from the arguments, so you write Swap(ref x, ref y) rather than Swap<int>(ref x, ref y).

C#Program.cs
var name = new Box<string>("Ada");
var age = new Box<int>(36);
Console.WriteLine($"{name.Value} is {age.Value}");

int x = 1, y = 2;
Swap(ref x, ref y);                 // T inferred as int
Console.WriteLine($"x={x} y={y}");

var pair = new Pair<string, double>("pi", 3.14);
Console.WriteLine(pair);

static void Swap<T>(ref T a, ref T b)
{
    T tmp = a;
    a = b;
    b = tmp;
}

class Box<T>
{
    public T Value { get; }
    public Box(T value) => Value = value;
}

record Pair<TFirst, TSecond>(TFirst First, TSecond Second);
Outputcompiled & run with real C#
Ada is 36
x=2 y=1
Pair { First = pi, Second = 3.14 }
Your turn

Give Box<T> a method Box<TOut> Map<TOut>(Func<T, TOut> f) and turn name into a box holding its length.

Why not just use object?
Before generics (C# 1) collections stored object. Every read needed a cast that could fail at runtime, and every int was boxed into a heap object on the way in. .NET generics are real at runtime: List<int> is compiled to code that stores raw ints, with no boxing and no casts. That is why you never see ArrayList in modern code.
05

Constraints with where

Inside a plain generic method the compiler knows nothing about T, so it only lets you do what works for every type: assign it, pass it, call ToString(). A where clause constrains T — "T must implement IComparable<T>" — and in return you may use those members.

Error you will hit

CS0019: comparing two T values with >

C#
Console.WriteLine(Max(3, 7));

static T Max<T>(T a, T b) => a > b ? a : b;
Program.cs(3,30): error CS0019: Operator '>' cannot be applied to operands of type 'T' and 'T'
Why the compiler said that

T could be string, Stream or your own class — most types have no > operator. The compiler has to reject code that would not work for every possible T.

The fix

Constrain T to IComparable<T> and call CompareTo, which every comparable type has.

C#
Console.WriteLine(Max(3, 7));

static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) > 0 ? a : b;
C#Program.cs
Console.WriteLine(Max(3, 7));
Console.WriteLine(Max("pear", "apple"));
Console.WriteLine(Largest(new List<double> { 2.5, 9.1, 4.0 }));

var list = Make<List<int>>();
list.Add(1);
Console.WriteLine($"made a list with {list.Count} item");

static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) > 0 ? a : b;

static T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
{
    T best = items.First();
    foreach (T item in items)
        if (item.CompareTo(best) > 0) best = item;
    return best;
}

static T Make<T>() where T : new() => new T();
Outputcompiled & run with real C#
7
pear
9.1
made a list with 1 item
ConstraintMeansLets you
where T : classT is a reference typecompare with null, use as
where T : structT is a non-nullable value typeuse T? as Nullable<T>
where T : notnullT is not a nullable typeuse T as a dictionary key without warnings
where T : new()T has a public parameterless constructorwrite new T()
where T : IComparable<T>T implements the interfacecall CompareTo
where T : AnimalT is Animal or derives from itcall Animal's members
where T : INumber<T>T is a numeric type (.NET 7+)use +, *, T.Zero generically
C#Program.cs
using System.Numerics;

Console.WriteLine(Sum(new[] { 1, 2, 3 }));
Console.WriteLine(Sum(new[] { 1.5m, 2.25m }));

// generic math: one method for int, decimal, double, long...
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (T v in values) total += v;
    return total;
}
Outputcompiled & run with real C#
6
3.75

INumber<T> uses static abstract interface members, which is how T.Zero and + work on a type parameter.

06

IEnumerable<T> and yield return

IEnumerable<T> is the smallest collection interface in .NET: "you can walk through me with foreach". Arrays, lists, sets, dictionaries and every LINQ query implement it. Accept IEnumerable<T> as a parameter type when you only need to loop, and your method works with all of them.

A method whose return type is IEnumerable<T> can use yield return to hand out items one at a time. The compiler turns it into a state machine: the body does not run when you call the method, it runs piece by piece each time foreach asks for the next item. That makes infinite sequences possible, as long as the caller stops.

C#Program.cs
foreach (int n in Evens().Take(4))
    Console.Write(n + " ");
Console.WriteLine();

var seq = Countdown(3);          // nothing printed yet
Console.WriteLine("created");
foreach (int n in seq)
    Console.WriteLine($"got {n}");

static IEnumerable<int> Evens()
{
    for (int i = 0; ; i += 2)
        yield return i;           // infinite, but lazy
}

static IEnumerable<int> Countdown(int from)
{
    Console.WriteLine("start");
    for (int i = from; i > 0; i--)
        yield return i;
    Console.WriteLine("end");
}
Outputcompiled & run with real C#
0 2 4 6 
created
start
got 3
got 2
got 1
end
Your turn

Write Fibonacci() as an infinite yield sequence and print the first 10 with Take(10).

VisualizeWho runs when with yield returnStep 1 / 8
var seq = Two();
foreach (int n in seq)
Console.WriteLine($"got {n}");
static IEnumerable<int> Two()
{
Console.WriteLine("A");
yield return 1;
Console.WriteLine("B");
yield return 2;
}
Line 1

Calling Two() only builds the state machine. Line 7 has not run.

Variables now

nothing yet

All 8 steps as a table
StepLineWhat happenedVariables now
11Calling Two() only builds the state machine. Line 7 has not run.
22foreach asks for the first item, so the body starts.
37Runs until the first yield return.
48Hands back 1 and pauses here.
53The loop body runs with n = 1.n = 1
69Asking for the next item resumes right after line 8.
710Hands back 2 and pauses again.
83Loop body with n = 2. Asking again runs off the end of Two(), so the loop stops.n = 2

Covariance: why IEnumerable fits IEnumerable but List does not
Error you will hit

CS0029: a List<string> is not a List<object>

C#
List<string> names = new() { "Ana", "Ben" };
List<object> items = names;
Program.cs(2,22): error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.List<object>'
Why the compiler said that

If this were allowed you could write items.Add(42) and put an int into a list of strings. Because List<T> lets you both read and write T, it is invariant: List<string> and List<object> are unrelated types.

The fix

IEnumerable<out T> only ever hands T out, so it is covariant: a sequence of strings is safely a sequence of objects. Use it when you only need to read.

C#
List<string> names = new() { "Ana", "Ben" };
IEnumerable<object> items = names;   // OK: read-only view
out and in on interfaces
out T (covariant) appears on interfaces that only return T: IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult>. in T (contravariant) appears on those that only accept T: IComparer<in T>, Action<in T>. An Action<object> can be used where an Action<string> is wanted, because anything that handles any object handles a string.
07

Choosing a collection

Pick by the question your code asks most often. "Give me item 5" wants a list. "Give me the item with id X" wants a dictionary. "Have I seen this?" wants a set. "What came in first?" wants a queue. "Undo the last thing" wants a stack. "Walk them in sorted order" wants a sorted collection.

CollectionLookupAddOrdered?Use for
T[]O(1) by indexfixed sizeinsertionfixed-size data, hot loops
List<T>O(1) by index, O(n) by valueO(1) at endinsertionthe default ordered collection
Dictionary<K,V>O(1) by keyO(1)noid to record, counting, caching
HashSet<T>O(1) containsO(1)nodedupe, membership, set maths
Queue<T>front onlyO(1)FIFOwork queues, BFS
Stack<T>top onlyO(1)LIFOundo, parsing, DFS
SortedDictionary<K,V>O(log n)O(log n)by keyordered output, range queries
PriorityQueue<T,P>min onlyO(log n)by priorityschedulers, Dijkstra
C#Program.cs
IReadOnlyList<string> roles = new List<string> { "admin", "editor" };
Console.WriteLine($"{roles.Count} roles, first {roles[0]}");

var jobs = new PriorityQueue<string, int>();
jobs.Enqueue("send newsletter", 3);
jobs.Enqueue("fix outage", 1);
jobs.Enqueue("review PR", 2);
while (jobs.TryDequeue(out string? job, out int priority))
    Console.WriteLine($"{priority}: {job}");
Outputcompiled & run with real C#
2 roles, first admin
1: fix outage
2: review PR
3: send newsletter
Return read-only interfaces from your APIs
A method that returns List<T> invites every caller to Add to your internal state. Return IReadOnlyList<T> or IReadOnlyDictionary<K,V> instead: the caller can read and loop, and a code reviewer can see at a glance that nothing is mutated. For data that must never change, System.Collections.Immutable has ImmutableArray and ImmutableDictionary.
Generic type
A class, struct, interface or method with a type parameter, such as List<T>, filled in at use as List<int>.
Type argument
The concrete type supplied for a type parameter: the int in List<int>.
Constraint
A where clause limiting which types T may be, which in turn unlocks members you can use on T.
Hash table
The structure behind Dictionary and HashSet: a hash code picks a bucket, giving O(1) average lookups.
IEnumerable<T>
The interface for anything foreach can walk; the input and output type of LINQ.
yield return
Returns one item from an iterator method and pauses it until the next item is requested.
Covariance
Using a more derived type argument where a less derived one is expected, allowed on out T interfaces like IEnumerable.
Boxing
Wrapping a value type in a heap object so it can be stored as object; generics avoid it.
Quick check

You need to check, millions of times, whether an email address is already registered. Which collection?

Quick check

When does the body of a method that uses yield return start running?

Frequently asked questions

What is the difference between a List and an array in C#?
An array has a fixed length set when it is created; a List grows as you add items by reallocating its internal array. Use an array for fixed-size data and hot numeric loops, and List as the default when the size changes.
Is Dictionary ordered in C#?
No. Dictionary does not guarantee enumeration order, even if it often looks like insertion order. Use SortedDictionary to keep keys sorted, or sort the entries with OrderBy when you print them.
What does where T : class mean in C#?
It is a generic constraint saying the type argument must be a reference type. Constraints like class, struct, new(), notnull or an interface name limit which types can be used and let the method use the members those types guarantee.

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.