Implement IEnumerable and IList in C# Custom Collections

Creating custom collection classes in C# allows developers to encapsulate internal data structures, enforce domain-specific validation rules, and control how elements are accessed and modified. This article provides a straightforward guide to implementing the IEnumerable<T> and IList<T> interfaces in C#. You will learn the role of each interface, review a complete implementation using an underlying array, and see how to consume the custom collection with standard C# features like indexers and foreach loops.


Understanding the Interface Hierarchy

To implement a full-featured indexed collection in C#, you must understand the relationship between the core collection interfaces:

  1. IEnumerable<T>: Enables forward-only iteration over a collection using foreach and allows full compatibility with LINQ. It also requires the implementation of the non-generic IEnumerable.
  2. ICollection<T>: Extends IEnumerable<T> by adding count tracking, read-only status, and methods for adding, removing, and checking for elements.
  3. IList<T>: Extends ICollection<T> by introducing indexed access (this[int index]), insertion at specific indices (Insert), and removal at specific indices (RemoveAt).

Because IList<T> inherits from ICollection<T> and IEnumerable<T>, implementing IList<T> requires you to satisfy the contracts for all three interfaces.


Building the Custom Collection

Below is a complete implementation of a generic custom collection named SimpleList<T>, backed by an internal array that dynamically resizes as items are added.

using System;
using System.Collections;
using System.Collections.Generic;

public class SimpleList<T> : IList<T>
{
    private T[] _items;
    private int _count;
    private const int DefaultCapacity = 4;

    public SimpleList(int capacity = DefaultCapacity)
    {
        _items = new T[capacity > 0 ? capacity : DefaultCapacity];
        _count = 0;
    }

    // --- ICollection<T> Members ---

    public int Count => _count;

    public bool IsReadOnly => false;

    public void Add(T item)
    {
        EnsureCapacity(_count + 1);
        _items[_count] = item;
        _count++;
    }

    public void Clear()
    {
        Array.Clear(_items, 0, _count);
        _count = 0;
    }

    public bool Contains(T item)
    {
        return IndexOf(item) >= 0;
    }

    public void CopyTo(T[] array, int arrayIndex)
    {
        if (array == null)
            throw new ArgumentNullException(nameof(array));
        if (arrayIndex < 0 || arrayIndex + _count > array.Length)
            throw new ArgumentOutOfRangeException(nameof(arrayIndex));

        Array.Copy(_items, 0, array, arrayIndex, _count);
    }

    public bool Remove(T item)
    {
        int index = IndexOf(item);
        if (index >= 0)
        {
            RemoveAt(index);
            return true;
        }
        return false;
    }

    // --- IList<T> Members ---

    public T this[int index]
    {
        get
        {
            ValidateIndex(index);
            return _items[index];
        }
        set
        {
            ValidateIndex(index);
            _items[index] = value;
        }
    }

    public int IndexOf(T item)
    {
        return Array.IndexOf(_items, item, 0, _count);
    }

    public void Insert(int index, T item)
    {
        if (index < 0 || index > _count)
            throw new ArgumentOutOfRangeException(nameof(index));

        EnsureCapacity(_count + 1);

        if (index < _count)
        {
            Array.Copy(_items, index, _items, index + 1, _count - index);
        }

        _items[index] = item;
        _count++;
    }

    public void RemoveAt(int index)
    {
        ValidateIndex(index);

        _count--;
        if (index < _count)
        {
            Array.Copy(_items, index + 1, _items, index, _count - index);
        }

        _items[_count] = default!;
    }

    // --- IEnumerable<T> & IEnumerable Members ---

    public IEnumerator<T> GetEnumerator()
    {
        for (int i = 0; i < _count; i++)
        {
            yield return _items[i];
        }
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    // --- Internal Helpers ---

    private void EnsureCapacity(int minCapacity)
    {
        if (_items.Length < minCapacity)
        {
            int newCapacity = _items.Length == 0 ? DefaultCapacity : _items.Length * 2;
            if (newCapacity < minCapacity)
            {
                newCapacity = minCapacity;
            }

            Array.Resize(ref _items, newCapacity);
        }
    }

    private void ValidateIndex(int index)
    {
        if (index < 0 || index >= _count)
        {
            throw new ArgumentOutOfRangeException(nameof(index), "Index was out of range.");
        }
    }
}

Key Implementation Details

  1. The Indexer (this[int index]): Defined by IList<T>, this property allows elements to be accessed or updated using bracket notation. It requires boundary checks against _count rather than _items.Length to prevent accessing allocated but unused buffer space.
  2. GetEnumerator() and yield return: The generic IEnumerable<T> requires returning an IEnumerator<T>. Using the C# yield return keyword simplifies this by creating an internal state machine that manages enumeration without building a separate custom enumerator class.
  3. Explicit Non-Generic Enumeration: Because IEnumerable<T> inherits from IEnumerable, you must provide the non-generic GetEnumerator() method. Implementing it explicitly and delegating to the generic version prevents API pollution while satisfying runtime requirements.
  4. Resizing and Boundary Handling: When shifting elements during Insert or RemoveAt, Array.Copy ensures efficient memory moves. When removing elements, setting the cleared index to default! avoids holding references that could cause memory leaks with reference types.

Using the Custom Collection

Once implemented, the custom collection works seamlessly with standard C# collection operations and LINQ queries:

var numbers = new SimpleList<int> { 10, 20, 30 };

// Access via indexer
numbers[1] = 25;

// Insert and Remove
numbers.Insert(1, 15);
numbers.Remove(30);

// Iteration via IEnumerable<T>
foreach (int number in numbers)
{
    Console.WriteLine(number); // Outputs: 10, 15, 25
}