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.
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.
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.
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.
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.
foreach works — nothing else is promisedCount, no Add, no Contains
Count, Add, Remove, Contains, ClearCircularBuffer<T> implements both — IEnumerable<T> via yield return, and enough of ICollection<T>'s shape to feel like a first-class .NET collection.
public IEnumerator<string> GetEnumerator()
{
yield return "Ana";
yield return "Ben";
yield return "Cara";
}
yield return, the compiler treats it as an iterator method — it stops compiling it as ordinary code and instead generates an entire hidden enumerator class behind the scenes.foreach loop calls MoveNext() on the generated enumerator, execution runs from wherever it last left off, up to the next yield return — hands back that value as Current — and then genuinely pauses, mid-method, with all local state intact, until MoveNext() is called again.public IEnumerator<T> GetEnumerator()
{
for (int i = 0; i < _count; i++)
yield return _items[(_start + i) % _items.Length]; // loops, math, conditionals — all fine
}
if statements, even early yield break to stop iteration altogether — all of ordinary C# control flow works inside an iterator method, which is exactly what makes it so much easier to reason about than a hand-written enumerator class with its own manually-tracked position field.System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
IEnumerable.GetEnumerator() (required because IEnumerable<T> extends the older, non-generic IEnumerable) simply forwards to the generic version above.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 5Code → Meaning → Result:
CountUpTo looks like an ordinary method with a loop — no manual enumerator class anywhere in sight.IEnumerator<int> implementation that remembers exactly where i was between calls to MoveNext() — the exact bookkeeping the Foundations lesson's hand-written NameEnumerator did manually.foreach, and — once you reach LINQ later in the course — with every LINQ method too, since they all consume IEnumerable<T>.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)); // TrueNotice 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.
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.
IEnumerator<T>, whose MoveNext() tracks an internal "state" number recording exactly which yield return execution last stopped at — this is a state machine, and it's precisely the same category of compiler-generated machinery you'll eventually meet again with async/await.yield return (like the loop counter i in CircularBuffer<T>.GetEnumerator) is hoisted into a field on the compiler-generated class, instead of living on the regular call stack the way an ordinary local variable would.GetEnumerator() on an iterator method doesn't execute any of its code yet — it just constructs the state machine object, primed at the start. This is why an iterator method's exceptions (or side effects) don't happen at the moment you call it, but only as foreach actually starts pulling items via MoveNext() — a subtlety worth knowing, though its full implications (deferred execution) are covered properly once LINQ arrives later in the course.yield return doesn't "return" in the normal sense — it pauses, it doesn't exitAn 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."
IEnumerator<T> to already exist somewhere — implementing IEnumerable<T> is what makes the object walkable at allIt'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.
ICollection<T> is a real commitment — every member has to behave sensibly, even if that means throwingICollection<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.
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(); 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.
== 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.
foreach-able surface — exactly as the Foundations WeeklySchedule example did, just with more custom logic underneath here.List<T>, Queue<T>, SortedSet<T>, and the rest) already models what you need — reinventing them adds maintenance burden for no real benefit.IReadOnlyList<T> — a full custom type is unnecessary ceremony for that case.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.
foreach to workIEnumerable<T> gives, plus Count, Add, Remove, Contains, Clearyield return pauses execution mid-method — it doesn't exit like an ordinary return.yield return lets you write iteration logic as ordinary code with a loop, while the compiler generates a full, correct enumerator state machine behind the scenes.IEnumerable<T> is the minimum to make a custom type work with foreach (and, later, LINQ) — ICollection<T> adds a fuller, mutable-collection contract when your type genuinely needs it.CircularBuffer<T>, draws on generics, yield return-based iteration, and EqualityComparer<T>.Default-based comparison all at once — exactly the toolkit this entire module built up.NotSupportedException — a deliberate design choice, not a shortcut.List<T> or the other collections you already know.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?
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]?
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?
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?
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.