Course Home L1 — Core Interfaces L2 — Hash & Sorted L3 — Specialized L4 — LINQ Basics L5 — Advanced LINQ All Guides

Lesson 1: Core Interfaces & Linear Data Structures

Understand the hierarchy of collection interfaces and the performance characteristics of List<T>.

Why Collection Interfaces Matter

In C#, the collection framework is built on a layered system of interfaces. Each interface in the chain adds more capabilities. Understanding these layers is not just academic — it directly impacts the APIs you design. When you accept an IEnumerable<T> as a parameter, you're telling callers "I only need to read your data." When you require an IList<T>, you're saying "I need random access." This informs your consumers about your intent, keeps coupling low, and makes your code far more testable.

IEnumerable<T> — The Root of Everything

IEnumerable<T> lives in System.Collections.Generic and is the most fundamental collection interface in .NET. It exposes a single method: GetEnumerator(), which returns an IEnumerator<T>. That enumerator has three members:

Key insight IEnumerable<T> is forward-only and read-only. You cannot go back, you cannot jump to an index, and you cannot modify the collection through it. This minimal contract is precisely what makes it so powerful — it supports deferred execution and is the foundation of LINQ (which we explore in Lesson 3).

Under the hood, a foreach loop is syntactic sugar for calling GetEnumerator() and advancing with MoveNext():

// What the compiler generates from a foreach loop
IEnumerator<int> enumerator = collection.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int item = enumerator.Current;
        // Use item...
    }
}
finally
{
    enumerator.Dispose();  // IEnumerator<T> implements IDisposable
}

yield return — Lazy Sequences

One of the most powerful features tied to IEnumerable<T> is the yield return keyword. It allows you to build a state machine that produces elements one at a time, on demand. No array or list is created in memory — the values are generated as the consumer iterates:

public static IEnumerable<int> FibonacciSequence(int count)
{
    int a = 0, b = 1;
    for (int i = 0; i < count; i++)
    {
        yield return a;
        int temp = a;
        a = b;
        b = temp + b;
    }
}

// Usage — nothing runs until we iterate
foreach (var num in FibonacciSequence(10))
{
    Console.WriteLine(num);
}
Watch out Because yield return methods are lazy, exceptions inside the method body are not thrown until iteration begins — not when the method is called. This can make debugging confusing if you're not expecting deferred execution.

ICollection<T> — Adding Mutation

ICollection<T> extends IEnumerable<T> and introduces the ability to mutate the collection. It adds:

Interview insight A common interview question is: "What is the difference between .Count (property) and .Count() (LINQ method)?" The property on ICollection<T> returns a stored value in O(1). The LINQ extension method .Count() on IEnumerable<T> must enumerate the entire sequence in O(n) — unless the runtime detects the underlying type implements ICollection<T>, in which case it short-circuits to the property. Always prefer the property when available.

IList<T> — Random Access

IList<T> extends both ICollection<T> and IEnumerable<T>. Its major addition is index-based access:

Choosing between these interfaces in method signatures is about communicating intent:

// "I will only read through your data"
public void PrintAll(IEnumerable<string> items) { ... }

// "I need to add or remove elements"
public void ProcessQueue(ICollection<string> items) { ... }

// "I need to access items by their position"
public void SwapFirstAndLast(IList<string> items) { ... }

IReadOnlyCollection<T> and IReadOnlyList<T>

A parallel branch of the hierarchy that is critical for modern API design and frequently tested in interviews:

Interview insight: IEnumerable vs. IReadOnlyList A common question is: "If IEnumerable<T> is read-only, why do we need IReadOnlyList<T>?" The answer is that IEnumerable<T> only communicates "I can iterate" — but a caller can still cast it back to List<T> and mutate the underlying collection. IReadOnlyList<T> provides a stronger contract: it promises indexed access and the absence of mutation methods. Additionally, IReadOnlyCollection<T> exposes a Count property, whereas with plain IEnumerable<T> you'd need to call the LINQ .Count() method which may enumerate the entire sequence.
// Exposing data safely: return IReadOnlyList, not List
public class OrderService
{
    private readonly List<Order> _orders = new();

    // BAD: Caller can cast to List<Order> and call .Add()
    public IEnumerable<Order> GetOrdersUnsafe() => _orders;

    // BETTER: IReadOnlyList exposes Count + indexer, no mutation
    public IReadOnlyList<Order> GetOrders() => _orders.AsReadOnly();
    // .AsReadOnly() returns a ReadOnlyCollection<T> wrapper
    // that throws on any mutation attempt, even after casting
}

The Interface Hierarchy

Visualizing the relationship helps cement the concept. The mutable and read-only branches both stem from IEnumerable<T>:

            ┌──────────────────────────────┐
            │   IEnumerable<T>             │  GetEnumerator()
            └──────────────┬───────────────┘
                 ┌─────────┴─────────┐
          mutable branch       read-only branch
                 │                   │
  ┌──────────────▼────┐   ┌──────────▼──────────────────┐
  │ ICollection<T>    │   │ IReadOnlyCollection<T>      │
  │ Add, Remove, Count│   │ Count (no mutation)         │
  └──────────────┬────┘   └──────────┬──────────────────┘
                 │                   │
  ┌──────────────▼────┐   ┌──────────▼──────────────────┐
  │ IList<T>          │   │ IReadOnlyList<T>            │
  │ this[i], Insert   │   │ this[i] (read-only)         │
  └──────────────┬────┘   └──────────┬──────────────────┘
                 │                   │
                 └─────────┬─────────┘
            ┌──────────────▼───────────────┐
            │   List<T>                    │  Implements BOTH branches
            └──────────────────────────────┘

List<T> — Under the Hood

List<T> is the most commonly used collection in C#. It implements IList<T> and is backed by an internal array (T[]). Understanding its internals is crucial for performance-sensitive code.

How the Internal Array Grows

When you call Add() and the internal array is full, List<T> allocates a new array of double the current capacity, copies all existing elements, and then adds the new one. This means:

However, if you know the expected size upfront, you can eliminate resizing entirely by setting the capacity in the constructor:

// BAD: Will resize multiple times as elements are added
List<int> numbers = new List<int>();

// GOOD: Pre-allocate for 10,000 elements — no resizing needed
List<int> numbers = new List<int>(10000);

Big O Summary for List<T>

OperationComplexityNotes
Access by index [i]O(1)Direct array access
Add()O(1) amortizedO(n) on resize
Insert(index, item)O(n)Shifts all elements after index
Remove(item)O(n)Must search + shift
RemoveAt(index)O(n)Shifts elements after index
Contains(item)O(n)Linear search
Find(predicate)O(n)Linear search with lambda
Sort()O(n log n)Uses introspective sort
BinarySearch()O(log n)List must be sorted first

Other Useful List<T> Methods

Beyond the basics, List<T> offers several methods worth knowing for interviews:

Polymorphic Views of the Same Object

Because List<T> implements all three interfaces, a single list object can be referenced through different "lenses," each exposing a different level of capability:

// Pre-defining capacity keeps Add() at O(1)
List<int> numbers = new List<int>(1000);
numbers.Add(42);
numbers.Add(99);
numbers.Add(7);

// View 1: Read-only iteration (no access to Add, Remove, or indexer)
IEnumerable<int> readOnlyView = numbers;

// View 2: Full indexable access
IList<int> indexableView = numbers;
int value = indexableView[0]; // 42 — Allowed!

// View 3: Mutation without indexing
ICollection<int> collectionView = numbers;
collectionView.Add(55);     // Allowed
// collectionView[0];         // NOT allowed — ICollection has no indexer
Design principle In your method signatures, always accept the least capable interface that still allows you to do your work. If you only need to iterate, accept IEnumerable<T>. This makes your methods more flexible (they work with arrays, lists, sets, LINQ queries, etc.) and communicates your intent clearly.

Bonus: Arrays and Other Linear Structures

Arrays (T[])

Arrays are the most primitive collection in C#. They have a fixed size set at creation time and offer the fastest possible element access. Arrays implement IList<T> but throw NotSupportedException for Add() and Remove() since they cannot be resized. Use arrays when the size is known and fixed, and performance is critical.

LinkedList<T>

A doubly-linked list where each node points to its previous and next neighbor. It excels at O(1) insertions and deletions at known positions (via LinkedListNode<T>) but has O(n) search and no indexing. It is rarely used in modern C# because List<T>'s CPU cache locality typically makes it faster in practice for most workloads.

Queue<T> and Stack<T>

Queue<T> is a FIFO (First-In, First-Out) collection — elements are enqueued at the back and dequeued from the front. Stack<T> is LIFO (Last-In, First-Out) — elements are pushed and popped from the top. Both offer O(1) for their primary operations.

// Queue: FIFO
Queue<string> printJobs = new Queue<string>();
printJobs.Enqueue("Report.pdf");
printJobs.Enqueue("Invoice.pdf");
string next = printJobs.Dequeue(); // "Report.pdf"

// Stack: LIFO
Stack<string> undoHistory = new Stack<string>();
undoHistory.Push("Typed 'Hello'");
undoHistory.Push("Deleted line");
string lastAction = undoHistory.Pop(); // "Deleted line"

PriorityQueue<TElement, TPriority> (.NET 6+)

A min-heap-based priority queue that dequeues elements in order of their priority value. Unlike Queue<T> (FIFO), it always returns the element with the lowest priority first. This is essential for algorithms like Dijkstra's shortest path, event scheduling, and merging K sorted lists.

var pq = new PriorityQueue<string, int>();

pq.Enqueue("Low priority task", 10);
pq.Enqueue("Critical fix", 1);
pq.Enqueue("Medium task", 5);

string next = pq.Dequeue(); // "Critical fix" (priority 1 — lowest first)

// Peek without removing
pq.TryPeek(out string element, out int priority);
// element = "Medium task", priority = 5
Gotcha .NET's PriorityQueue does not support updating the priority of an existing element. If you need decrease-key for algorithms like Dijkstra, you typically re-enqueue with the new priority and track visited nodes separately. There is also no built-in way to enumerate in priority order — you must dequeue repeatedly.

Coding Challenge

Write a method that takes an IEnumerable<string>, iterates through it without using LINQ, filters out strings shorter than 5 characters, and returns them as an IList<string>. Optimize your underlying list for performance if you can estimate the capacity.

Constraints: No LINQ methods (Where, Select, ToList, etc.). Use only manual iteration.

View Solution
public static IList<string> FilterLongStrings(IEnumerable<string> input)
{
    // We can't know the exact count from IEnumerable alone,
    // but if the underlying type is ICollection, we can get a hint.
    int estimatedCapacity = 16; // sensible default

    if (input is ICollection<string> collection)
    {
        // At most, all items match, so use the collection's count
        estimatedCapacity = collection.Count;
    }

    List<string> result = new List<string>(estimatedCapacity);

    foreach (string s in input)
    {
        if (s != null && s.Length >= 5)
        {
            result.Add(s);
        }
    }

    // Optional: reclaim unused memory if the result is much smaller
    result.TrimExcess();

    return result;
}

// Usage:
string[] words = { "cat", "elephant", "dog", "giraffe", "owl", "zebra" };
IList<string> longWords = FilterLongStrings(words);
// Result: ["elephant", "giraffe", "zebra"]

Why this solution is strong:

  • It accepts the most generic interface (IEnumerable<T>) as input, maximizing flexibility.
  • It attempts to optimize capacity by checking if the runtime type is ICollection<T>.
  • It returns IList<T> (interface) rather than List<T> (concrete class), following the principle of coding to an interface.
  • It handles null strings defensively.
  • It calls TrimExcess() to reclaim unused capacity after filtering.