IEnumerable and Iteration
The single most important interface in LINQ, the iterator pattern underneath it, and what 'a sequence' actually means in C#
LINQ is built on a single, deceptively small interface:
IEnumerable<T>. Every collection type you'll ever use in
C# — List<T>, T[], Dictionary<TKey, TValue>.Values,
HashSet<T>, the output of every LINQ operator — is an
IEnumerable<T>.
This page is about what that interface actually is, what it promises, and what it deliberately does not promise. Once you understand this, every later page in the course makes more sense.
The interface itself
IEnumerable<T> is two methods:
public interface IEnumerable<T>
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<T> : IDisposable
{
T Current { get; }
bool MoveNext();
void Reset(); // rarely used in modern code
}That's it. The contract is:
"You can ask me for an enumerator, which lets you walk through my elements one at a time. Each
MoveNext()either advances and returnstrue, or returnsfalseif we've reached the end."
Notice what's not there:
- No
Count. You may not know how many elements there are. - No
Indexer. You can't ask for "element 7". - No
AddorRemove. Sequences may be immutable, infinite, or computed.
This minimal contract is exactly what makes LINQ work uniformly over arrays, files, network streams, and infinite sequences.
foreach is just sugar
A foreach loop in C# is just a sugar over GetEnumerator/
MoveNext/Current. These two programs are equivalent.
When you call a LINQ operator like Where(...), you receive
something that implements IEnumerable<T> — but the elements have
not been computed yet. They are computed on demand, one at a time,
as MoveNext() is called. This is the foundation of
deferred execution, which we'll cover in
detail later.
Building your own sequence with yield
The easiest way to produce an IEnumerable<T> is the yield return
keyword. The compiler turns your method into a state machine that
implements IEnumerator<T> for you.
Read that carefully. Naturals() would loop forever if you let it.
But because IEnumerable<T> is pull-based — values are only
produced when the consumer asks — it is safe to define and even
safer to use with operators that stop early.
A custom operator written from scratch
To prove how simple the contract is, let's implement our own Where.
That's the entire implementation. The built-in Enumerable.Where is
a touch more sophisticated (it specializes for common types), but the
core idea is exactly the eight lines above.
This is the moment LINQ stops looking magical.
What IEnumerable<T> does not promise
This is where most beginner LINQ bugs come from. IEnumerable<T>
explicitly does not promise:
| What people assume | What's actually true |
|---|---|
You can call .Count() cheaply | It may walk the entire sequence |
| You can iterate twice | It may be a one-shot sequence (a network stream, a yield method) |
| Elements are stored in memory | They may be computed on demand |
.Count() and foreach will agree | They may not, if the source is changing |
Practical example of the "iterate twice" trap:
The source method runs twice. If Source() were reading from a
file or making HTTP calls, that's two disk reads or two network
trips — usually not what you want.
The fix is to materialize the sequence once, into a real collection:
Now Source() runs only once. The two passes iterate the
in-memory List<int>.
Materializing: ToList, ToArray, ToDictionary
These three operators end a pipeline and return a concrete collection. They are the moment "deferred LINQ" becomes "actual data sitting in memory".
A useful rule:
Stay in
IEnumerable<T>for as long as possible. Materialize exactly once, at the end, when you need to consume the result more than once or want to know the count.
IReadOnlyCollection, ICollection, IList — quick map
It's worth a glance at how the broader hierarchy looks. Most of the
time you should type your variables as IEnumerable<T> unless you
need more.
IEnumerable<T>— can iterate.IReadOnlyCollection<T>— can iterate + hasCount.IReadOnlyList<T>— adds indexing.ICollection<T>/IList<T>— add mutation.
If your method only needs to iterate, accept IEnumerable<T>.
If you need Count but no mutation, accept
IReadOnlyCollection<T>. If you accept List<T> "just in case",
you've locked your caller into one concrete type for no reason.
A method has the signature IEnumerable<int> GetTemperatures(). Which assumption about the returned value is safe?
The returned sequence has a known Count property you can read cheaply.
You can iterate the returned sequence multiple times and always get the same results.
You can call foreach over the result and read elements one at a time.
The results are already in memory and indexing is O(1).