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:
IEnumerable<T>: Enables forward-only iteration over a collection usingforeachand allows full compatibility with LINQ. It also requires the implementation of the non-genericIEnumerable.ICollection<T>: ExtendsIEnumerable<T>by adding count tracking, read-only status, and methods for adding, removing, and checking for elements.IList<T>: ExtendsICollection<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
- The Indexer (
this[int index]): Defined byIList<T>, this property allows elements to be accessed or updated using bracket notation. It requires boundary checks against_countrather than_items.Lengthto prevent accessing allocated but unused buffer space. GetEnumerator()andyield return: The genericIEnumerable<T>requires returning anIEnumerator<T>. Using the C#yield returnkeyword simplifies this by creating an internal state machine that manages enumeration without building a separate custom enumerator class.- Explicit Non-Generic Enumeration: Because
IEnumerable<T>inherits fromIEnumerable, you must provide the non-genericGetEnumerator()method. Implementing it explicitly and delegating to the generic version prevents API pollution while satisfying runtime requirements. - Resizing and Boundary Handling: When shifting
elements during
InsertorRemoveAt,Array.Copyensures efficient memory moves. When removing elements, setting the cleared index todefault!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
}