← Open in the full interactive course (progress tracking, search & more)

Every generic collection you've used this module was built from the same handful of pieces you now know. This is the lesson where you build one yourself.

Look back at everything you've learned since the "Generics" lesson opened this module: generic classes and methods, constraints, variance, the collections toolkit, hash tables, equality, hashing, natural ordering. Every single one of those ideas is a piece of how List<T>, Dictionary<TKey,TValue>, and every other collection in .NET is actually built. You've been using the finished product this whole time — this lesson is where you build one yourself, from scratch.

The goal is a CircularBuffer<T> — a fixed-capacity buffer where adding a new item once it's full silently overwrites the oldest one. It's genuinely useful (recent-activity logs, rolling metrics, "last N readings" buffers) and small enough to build completely in one lesson.

In this lesson, you'll implement IEnumerable<T> using yield return — connecting back to the first-look version from Foundations — see when implementing ICollection<T> is worth the extra effort, and build a real, working generic collection that ties together everything this module has taught.

What Is It?

The Simple Explanation

A custom collection is a generic type you write yourself that behaves like the built-in ones — you can foreach over it, and depending on how much of the collection interface hierarchy it implements, count its items, add to it, or check whether it contains something. Building one isn't about reinventing List<T> — it's about giving a genuinely different storage shape (a fixed-size ring, in this lesson's case) the same familiar, idiomatic surface every other .NET collection has.

The Technical Definition

At minimum, a custom collection implements IEnumerable<T> — the single-method contract from Foundations that unlocks foreach. Optionally, it can implement ICollection<T>, which extends IEnumerable<T> with Count, Add, Remove, Contains, Clear, and a few more members — the fuller contract that lets your type plug into APIs expecting a genuine, mutable collection, not just something walkable. The most practical way to implement the enumeration part is yield return, a C# keyword that lets you write iteration logic as ordinary-looking code while the compiler generates the entire enumerator class behind the scenes.

Why Does It Exist?

The Problem

Back in the Foundations first-look lesson, you hand-wrote an IEnumerator<T> class to make NameList enumerable — a separate class, tracking its own position field, with MoveNext(), Current, and Reset() all written out by hand. That works, but it's verbose for something conceptually simple: "walk through my items, one at a time." For a genuinely non-trivial iteration order — like a circular buffer, which has to start reading from wherever the oldest item currently sits, not always from index 0 — hand-writing that enumerator class gets fiddly fast, with plenty of room for off-by-one bugs in the position-tracking logic.

The Solution

yield return lets you write the iteration logic as a normal-looking method with a loop — no manual position field, no hand-written MoveNext() — and the C# compiler transforms it into a full, correct enumerator class for you, automatically. This is exactly what the Foundations lesson flagged as "deliberately out of scope for now" — this lesson is where that promise gets paid off.

Big Picture

HOW MUCH OF THE COLLECTION INTERFACE HIERARCHY DO YOU NEED?
IEnumerable<T> only
"Can be walked through"
foreach works — nothing else is promised
No Count, no Add, no Contains
ICollection<T>
Everything IEnumerable<T> gives, plus:
Count, Add, Remove, Contains, Clear
A genuine, mutable collection contract
This lesson's CircularBuffer<T> implements both — IEnumerable<T> via yield return, and enough of ICollection<T>'s shape to feel like a first-class .NET collection.

How It Works

yield return, STEP BY STEP
1. WRITE A METHOD RETURNING IEnumerable<T> (OR IEnumerator<T>)
public IEnumerator<string> GetEnumerator()
{
    yield return "Ana";
    yield return "Ben";
    yield return "Cara";
}
2. EACH yield return PRODUCES ONE ITEM, THEN PAUSES
3. ORDINARY CONTROL FLOW WORKS INSIDE AN ITERATOR
public IEnumerator<T> GetEnumerator()
{
    for (int i = 0; i < _count; i++)
        yield return _items[(_start + i) % _items.Length];   // loops, math, conditionals — all fine
}
4. IEnumerable<T> CAN SIMPLY CALL THIS ITERATOR METHOD
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();

Simple Example

First, the smallest possible version — yield return alone, no state, just to see the mechanism in isolation:

public static IEnumerable<int> CountUpTo(int max) { for (int i = 1; i <= max; i++) yield return i; } foreach (int n in CountUpTo(5)) Console.Write($"{n} "); // 1 2 3 4 5

Code → Meaning → Result:

Real-World Example

Now the full capstone: a CircularBuffer<T> that holds the last N readings from a sensor, a log, or an activity feed — a genuinely useful, genuinely common shape that List<T> doesn't offer out of the box. This brings together generics (the <T> itself), IEnumerable<T> via yield return, and enough of ICollection<T>'s shape to make it feel like a first-class collection.

public class CircularBuffer<T> : ICollection<T> { private readonly T[] _items; private int _start; // index of the oldest item private int _count; // how many slots are currently filled public CircularBuffer(int capacity) { if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); _items = new T[capacity]; } public int Count => _count; public bool IsReadOnly => false; public void Add(T item) { int writeIndex = (_start + _count) % _items.Length; _items[writeIndex] = item; if (_count < _items.Length) { _count++; // buffer not yet full — just grows } else { _start = (_start + 1) % _items.Length; // buffer full — oldest item is overwritten, advance start } } public void Clear() { Array.Clear(_items); _start = 0; _count = 0; } public bool Contains(T item) => this.Any(x => EqualityComparer<T>.Default.Equals(x, item)); public void CopyTo(T[] array, int arrayIndex) { foreach (T item in this) array[arrayIndex++] = item; } public bool Remove(T item) => throw new NotSupportedException("CircularBuffer does not support removing individual items."); // The heart of the type — yield return walks the buffer from oldest to newest, // correctly wrapping around the underlying array. public IEnumerator<T> GetEnumerator() { for (int i = 0; i < _count; i++) yield return _items[(_start + i) % _items.Length]; } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } // ─── Usage: the last 3 sensor readings, oldest overwritten automatically ─── var recentReadings = new CircularBuffer<double>(capacity: 3); recentReadings.Add(21.5); recentReadings.Add(21.7); recentReadings.Add(21.6); recentReadings.Add(22.1); // buffer was full — 21.5 is silently overwritten foreach (double reading in recentReadings) Console.Write($"{reading} "); // 21.7 21.6 22.1 ← oldest-to-newest, always exactly the last 3 readings Console.WriteLine(recentReadings.Count); // 3 — never exceeds capacity Console.WriteLine(recentReadings.Contains(22.1)); // True

Notice Contains calls this.Any(...) — that's a LINQ method, working here purely because CircularBuffer<T> implements IEnumerable<T>, exactly the payoff the Foundations lesson promised. And Contains uses EqualityComparer<T>.Default.Equals(...) rather than a raw == — the exact mechanism from the previous lesson that automatically prefers IEquatable<T> when the element type provides it.

Analogy

A Movie You Can Pause and Resume

Think of a normal method as a movie you have to watch start to finish in one sitting — call it, and it runs to completion before handing anything back. An iterator method (one using yield return) is more like a movie with a genuine pause button: each MoveNext() call presses play, the method runs exactly until it hits the next yield return, hands you that one "frame," and then truly pauses — remembering exactly where every local variable stood — until you press play again.

That pause-and-resume capability is precisely what makes yield return so much simpler than a hand-written enumerator class: the compiler is doing the work of remembering "where we paused" for you, instead of you tracking it yourself in a manually-managed position field.

Under the Hood

WHAT yield return REALLY COMPILES INTO
1. THE COMPILER GENERATES A STATE MACHINE
2. LOCAL VARIABLES BECOME FIELDS ON THE GENERATED CLASS
3. THE METHOD BODY DOESN'T ACTUALLY RUN UNTIL MoveNext() IS CALLED

Common Confusion

1. yield return doesn't "return" in the normal sense — it pauses, it doesn't exit

An ordinary return exits a method immediately and permanently. yield return hands back one value and pauses in place — the very next line still runs the next time MoveNext() is called. This is the single most important mental shift: think "pause and hand over a value," not "return and stop."

2. You don't need IEnumerator<T> to already exist somewhere — implementing IEnumerable<T> is what makes the object walkable at all

It's easy to conflate the two interfaces from the Foundations lesson: IEnumerable<T> ("can produce a walk-through") is what the collection type implements; IEnumerator<T> ("the walk-through itself") is what GetEnumerator() returns — and with yield return, you never write that second class by hand at all; the compiler generates it entirely from your iterator method.

3. Implementing ICollection<T> is a real commitment — every member has to behave sensibly, even if that means throwing

ICollection<T> requires a Remove method, but "remove this specific item from a circular buffer" doesn't have an obviously correct meaning (removing from the middle would break the fixed-capacity ring structure). Throwing NotSupportedException, as the example does, is a legitimate, well-precedented choice for a member that genuinely doesn't apply — .NET's own read-only collections do exactly this for their mutating members — but it's a deliberate design decision you should make consciously, not an oversight.

Common Mistakes

Mistake 1 — Forgetting the non-generic IEnumerable.GetEnumerator() forwarding

Wrong — doesn't compile; IEnumerable<T> extends the older, non-generic IEnumerable, which also needs implementing:

public class CircularBuffer<T> : IEnumerable<T> { public IEnumerator<T> GetEnumerator() { /* ... */ } // missing: System.Collections.IEnumerable.GetEnumerator() }

Correct — add the explicit interface implementation forwarding to the generic version, exactly as shown throughout this lesson and the Foundations one:

System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();

Mistake 2 — Getting the wraparound math wrong in a ring-shaped structure

Using plain addition without the modulo operator (_start + i instead of (_start + i) % _items.Length) — this walks off the end of the underlying array once _start is anywhere but 0, throwing an IndexOutOfRangeException or silently reading garbage. Every index into a circular buffer's backing array needs the modulo wraparound, as shown in GetEnumerator and Add above — this is the single trickiest part of the whole type, and worth testing carefully around the wraparound boundary specifically.

Mistake 3 — Using == instead of EqualityComparer<T>.Default inside a generic type's comparison logic

x == item inside a generic method doesn't even compile for an unconstrained T — exactly the constraint lesson's lesson from earlier in this module, since == isn't guaranteed for every possible type. EqualityComparer<T>.Default.Equals(x, item), as used in Contains above, works for any T and automatically prefers IEquatable<T> when available — tying directly back to the previous lesson.

When Should I Use It?

Build a custom collection when

Don't build one when

Rule of thumb: Implement IEnumerable<T> via yield return as your default approach to custom iteration — it's dramatically simpler than a hand-written enumerator class for anything beyond the most trivial case. Reach for the fuller ICollection<T> contract only once you genuinely need callers to add, remove, count, or check containment through a standard interface.

Mental Model

yield return = "hand back this one value, then pause right here until asked to continue"
IEnumerable<T> = "I can be walked through" — the minimum for foreach to work
ICollection<T> = everything IEnumerable<T> gives, plus Count, Add, Remove, Contains, Clear

Remember:
· yield return pauses execution mid-method — it doesn't exit like an ordinary return.
· The compiler generates a full state-machine enumerator class from your iterator method — you never hand-write it.
· This whole module's ideas — generics, constraints, hashing, equality, ordering, iteration — are exactly the pieces every .NET collection is built from.

Key Takeaway


Check Your Understanding

You've built a real custom collection from scratch, tying together the whole module. Let's check your understanding.

1. What does yield return actually do when a foreach loop calls MoveNext() on an iterator method?

Show answer

Correct: B

Why B is correct: As emphasized in "Common Confusion," yield return pauses rather than exits — it hands back exactly one value per MoveNext() call, and resumes exactly where it left off on the next call, with all local state intact.

Why A is incorrect: This confuses yield return with an ordinary return — the whole point of yield return is that it does NOT exit the method.

Why C is incorrect: The generated state machine specifically remembers where execution paused, so it never restarts from scratch on subsequent calls.

Why D is incorrect: Execution genuinely pauses one item at a time — it does not eagerly compute and buffer every value upfront.

Reinforcement: "Pause and hand over one value" is the core mental model for every yield return in an iterator method.

2. Why does CircularBuffer<T>.GetEnumerator() compute (_start + i) % _items.Length instead of simply indexing with _items[i]?

Show answer

Correct: B

Why B is correct: As explained in "Common Mistakes," once the buffer wraps around, the logically "oldest" item can sit anywhere in the underlying array — _start tracks exactly where, and the modulo wraparound is what lets iteration correctly walk from there, looping back to index 0 as needed.

Why A is incorrect: Plain _items[i] would read items in raw array order, not oldest-to-newest logical order — it would produce visibly wrong results once the buffer had wrapped even once.

Why C is incorrect: The modulo operator has nothing to do with yield return as a language feature — it's simply the correct math for this specific ring-buffer indexing problem.

Why D is incorrect: No type conversion happens here at all — this is purely an indexing calculation into the existing array.

Reinforcement: A ring-shaped structure needs wraparound math at every point it indexes into its backing array — this is the trickiest, most bug-prone part of building one.

3. Why does CircularBuffer<T>.Contains use EqualityComparer<T>.Default.Equals(x, item) instead of writing x == item directly?

Show answer

Correct: B

Why B is correct: This connects directly back to the constraints lesson earlier in this module — an unconstrained T gets no guarantee that == is defined for it, so it simply won't compile. EqualityComparer<T>.Default sidesteps that entirely, and — as covered in the previous lesson — automatically uses the faster IEquatable<T> path when the element type provides it.

Why A is incorrect: x == item doesn't compile at all for an unconstrained T — it's a compile-time failure, not a runtime behavior of always returning false.

Why C is incorrect: It's not mandated syntax for ICollection<T> specifically — it's simply the correct, idiomatic way to compare two values of an unconstrained generic type.

Why D is incorrect: The two aren't interchangeable at all — one fails to compile for a generic T, and the other is specifically designed to work correctly for any T.

Reinforcement: Comparing values of an unconstrained generic type parameter requires EqualityComparer<T>.Default (or an explicit constraint) — plain == simply isn't available.

4. CircularBuffer<T>.Remove(T item) throws NotSupportedException instead of actually removing an item. Is this a defect in the design?

Show answer

Correct: B

Why B is correct: As discussed in "Common Confusion," some interface members genuinely don't have a sensible implementation for a given type's shape — .NET's own read-only collections do the same thing for their mutating members. The key is that it's a deliberate, documented decision, not an oversight.

Why A is incorrect: C# doesn't enforce that every interface member has a "fully working" implementation in some universal sense — throwing a descriptive exception for a genuinely inapplicable operation is a well-established, valid pattern.

Why C is incorrect: The type still compiles and correctly implements the interface — throwing from one member doesn't prevent implementing the interface as a whole.

Why D is incorrect: Remove absolutely can be called by generic code written against ICollection<T> — which is exactly why throwing a clear, descriptive exception (rather than silently doing nothing incorrect) matters.

Reinforcement: Implementing a broad interface sometimes means deliberately rejecting an operation that doesn't structurally fit your type — as long as that's an intentional, well-communicated choice.

Congratulations — you've completed Part II: Generics & Collections. You now understand generics from first principles through variance, the full generic collections toolkit, exactly how Dictionary<TKey,TValue> achieves its speed, the Equals/GetHashCode contract, natural ordering with IComparable<T>, boxing-free equality with IEquatable<T>, and how to build a real, working custom collection with yield return. Every piece of this module is a piece of how .NET's own collections are built — you could, if you needed to, build your own.


dotnetmadeeasy.com — Learn C# and .NET, the right way.