Updated on

These four are one inheritance chain, not four alternatives. IEnumerable<T> can only be iterated; ICollection<T> adds Count and mutation; IList<T> adds indexing; and List<T> is the concrete class that implements all of them.

So the choice is really one question: how little can we get away with? Accept the weakest interface a method actually needs, and return the weakest one the caller actually needs. For read-only data in memory that is usually IReadOnlyCollection<T>, not any of these four.

To download the source code for this article, you can visit our GitHub repository.

Introduction to .NET Collection Interfaces

First, it’s essential to understand that there are two different versions of interfaces. One is a non-generic IEnumerable, ICollection, and IList interface and the other one is a type-safe generic IEnumerable<T>, ICollection<T>, and IList<T> interface.

IEnumerable is the base of all the others. ICollection inherits IEnumerable. IList inherits the ICollection. And List is a class implementation of the IList.

Two read-only interfaces sit alongside them and belong on the same map. IReadOnlyCollection<T> exposes a Count with no way to change the collection, and IReadOnlyList<T> adds an indexer to that. List<T> implements both, so a method that already builds a list can return either one without copying anything. For the wider picture, we also have an overview of collections in .NET.

In this article, we talk mainly about type-safe generic interfaces, notably in the examples. But the same applies to non-generic versions.

It’s also important to point out that the two versions don’t belong to the same namespace:

  • Non-Generic versions are in System.Collections
  • Generic versions are in System.Collections.Generic

What is IEnumerable<T> and When to Use It?

The IEnumerable<T> interface is the base for all the collections.

When we want to iterate only, then IEnumerable<T> is the best choice. It is for looping through the collection in the forward direction only. It supports deferred execution and filtering. When we need a read-only operation, we can use IEnumerable<T>.

But we can’t iterate backward. We can’t perform operations at a particular position. We can’t access any item by its index.

It has one method GetEnumerator(). It returns an IEnumerator<T> that can be used to iterate through the collection.

IEnumerator<T> provides methods like MoveNext(), Reset() and Current property.

One caveat about Reset(): it is declared on the interface, but not every implementation supports it. The iterator the compiler generates for a yield method throws NotSupportedException from Reset(), so in practice we enumerate the sequence again instead of resetting it.

Let’s understand this with an example:

public int CountSpecialCharacters(IEnumerable<char> specialCharacters)
{
    var count = 0;
    foreach(char c in specialCharacters)
    {
        count++;
    }

    return count;
}

We have a CountSpecialCharacters() method that takes specialCharacters of IEnumerable<char> type. To count the characters, we can use the for each loop which iterates over the specialCharacters.

The foreach uses GetEnumerator(), MoveNext() and Current to iterate over the collection hiding the complexity of Enumerators. That’s why we can use foreach with every type that implements IEnumerable<T> (that also means all the collection types we will tackle in this article).

There is another way to create IEnumerable<T> types (other than instantiating a concrete type like a List<T> for example) and that’s using the yield keyword. The yield keyword allows doing custom iteration while maintaining the state over a collection of items.

Let’s have a GetEvenNumberUpToTen() example:

public class ImplementationOfIEnumerable
{
    public IEnumerable<int> GetEvenNumberUpToTen()
    {
        yield return 0;
        yield return 2;
        yield return 4;
        yield return 6;
        yield return 8;
        yield return 10;
    }
}

The caller Main() method calls GetEvenNumberUpToTen() to get even numbers.

var enu = new ImplementationOfIEnumerable();
Console.WriteLine($"IEnumerable With Yield:");
foreach (var num in enu.GetEvenNumberUpToTen())
{
    Console.WriteLine(num);
}

When the Main() caller calls GetEvenNumberUpToTen() then every time the control moves to the next yield and returns the value.

What is ICollection<T> and When to Use It?

ICollection<T> is the interface that adds size and mutation to IEnumerable<T>. Microsoft’s reference calls it “the base interface for classes in the System.Collections.Generic namespace”. It is the smallest thing that can tell us how many items there are.

IEnumerable<T> declares one member, GetEnumerator(). ICollection<T> adds Count, Add(), Remove(), Clear(), Contains(), CopyTo(), and IsReadOnly: everything that treats a sequence as a container rather than as a stream.

Count is the reason to reach for it even in code that never mutates anything. On an IEnumerable<T>, the LINQ Count() method may have to walk the entire sequence to answer; on an ICollection<T>, Count is simply a value the collection already holds.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

IsReadOnly is the part most people miss. ICollection<T> does not promise that the collection can be changed. An array passed as ICollection<T> reports IsReadOnly as true and throws from Add(), which is the same trap the IList<T> section below demonstrates.

But we still can’t perform any index-related operations.

Let’s modify the above CountSpecialCharacters() method:

public int CountSpecialCharacters(ICollection<char> specialCharacters)
{
    specialCharacters.Add('~');
    specialCharacters.Add('!');

    var count = 0;
    foreach (char c in specialCharacters)
    {
        count++;
    }

    return count;
}

We have the same CountSpecialCharacters() method but this time specialCharacters is of ICollection<char> type. Also, instead of our iteration, we could use the Count property to show the number of elements inside the collection:

return specialCharacters.Count;

Now, not only can we loop through the characters but we can also Add() elements into the specialCharacters:

var icl = new ImplementationOfICollection();
ICollection<char> lst = new List<char>() { '^', '.' };

Assert.That(icl.CountSpecialCharacters(lst), Is.EqualTo(4));

This modified CountSpecialCharacters() method adds two more characters to the ICollection<char>. This returns the count as 4.

We can use ICollection when we need to perform any non-index related operations like Add(), Remove(), Contains(), CopyTo() etc.

What is IList<T> and When to Use It?

IList extends ICollection. It exposes all the ICollection functionalities and also adds its operations to it. IList allows index-related operations like Insert(), RemoveAt() etc.

Let’s add more functionality to the same example:

public int CountSpecialCharacters(IList<char> specialCharacters)
{
    specialCharacters.Add('~');
    specialCharacters.Add('!');

    specialCharacters.Insert(0, '$');

    var count = 0;
    foreach (char c in specialCharacters)
    {
        count++;
    }

    return specialCharacters.IndexOf('$').Equals(0) ? count : 0;
}

We have the same CountSpecialCharacters() method but this time it has specialCharacters of IList<char> type. Now, not only can we loop through, we can Add() elements into the specialCharacters and also perform index-related operations Insert() or IndexOf() to a specific position in the IList.

We call the CountSpecialCharacters() from Main() method:

var specialCharacter = new List<char>() {'#','@','$' };
var arrayOfSpecialCharacter = new char[] { '=', '!' };
var ils = new ImplementationOfIList();

var output = ils.CountSpecialCharacters(specialCharacter);
Console.WriteLine($"IList with List input:{output}");
output = ils.CountSpecialCharacters(arrayOfSpecialCharacter);
Console.WriteLine($"IList with Array input:{output}");

We pass the specialCharacter to the CountSpecialCharacters() which takes IList<char> type as input and returns the count. We may be tempted to pass an array to IList<T> parameter as we did in the example. but that can cause an issue since some methods like Add(), Insert() etc are not implemented.

The compiler lets us pass the array to the method because other functionalities are well-implemented and can be used properly.

In this example, on passing arrayOfSpecialCharacter of char[] type to CountSpecialCharacters() we get a runtime exception at Add() method:

System.NotSupportedException: 'Collection was of a fixed size.'

What is List<T> and When to Use It?

Till now we have seen all the interfaces, but List is a concrete class. List implements IList. List can be instantiated. We can use it whenever we want to have a generic list with a specific object type. List supports Sort() method.

Let’s take the same CountSpecialCharacters() method:

public int CountSpecialCharacters(List<char> specialCharacters)
{
    var count = 0;
    foreach (char c in specialCharacters)
    {
        count++;
    }

    return count;
}

We change only the input type to List<char>. We have a List of char types in which we can use all the functionalities of the above three interfaces. We can pass the specialCharacter of List<char> type to the CountSpecialCharacters().

Being a concrete class is also what makes a List<T> convenient to build, and collection expressions make that initialization shorter still.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

As List is a concrete class, when we expose a List instead of an interface, we couple the client to a specific implementation. We should choose the minimum required interface when exposing an API to different clients, in this way we allow flexibility to the client code and give them the choice to choose any implementation they want.

ICollection vs IEnumerable in C#: What Is the Difference?

ICollection<T> inherits IEnumerable<T>, so the question is not which is better but how much we need. Everything an IEnumerable<T> can do, an ICollection<T> can do too.

Three differences matter in practice. ICollection<T> has a Count property, so asking for the size is a lookup rather than a possible full pass. It has Add(), Remove(), and Clear(). And it is always a materialised container, where an IEnumerable<T> may be a query that has not run yet.

That last one causes the real bugs. An IEnumerable<T> handed back from a LINQ query re-executes on every foreach, so iterating it twice does the work twice, and against a database, queries twice.

Choosing a parameter type follows from the same logic. Take IEnumerable<T> when the method iterates once and reads. Take ICollection<T> when it needs the count, needs to add or remove, or needs a second pass.

Microsoft names the mechanism behind that: “Deferred execution means that the evaluation of an expression is delayed until its realized value is actually required.”

Given a numbers collection already in memory, three statements are enough to see it:

IEnumerable<int> query = numbers.Where(n => n > 10);

Console.WriteLine(query.Count());   // runs the filter
Console.WriteLine(query.Count());   // runs the filter again

ICollection<int> materialised = query.ToList();

Console.WriteLine(materialised.Count);   // reads a stored value

Both Count() calls walk numbers and apply the predicate again, because query is the filter itself rather than its result. Once we call ToList(), the work happens once and materialised.Count is a stored value we can read as often as we like.

The whole family fits in one picture, with each layer adding to the one above it:

IEnumerable, ICollection, IList and List in C# shown as one inheritance chain with what each layer adds

Deferred execution also has a second act once a query leaves memory, and our article on where IQueryable fits alongside these interfaces covers how a provider turns one into SQL.

Which Should We Use: IEnumerable, ICollection, IList, or List?

Return the least capable type that does the job, and accept the most capable one the method genuinely needs.

For a return type, IEnumerable<T> says the caller may read the sequence and nothing else. IReadOnlyCollection<T> adds a count without granting mutation, and it is usually the better answer for data already in memory: it closes the double-enumeration trap without handing anyone Add().

Return ICollection<T> or IList<T> only when the caller really is meant to modify our collection, which is rarer than it looks.

Never return List<T> from a public API. It ties every caller to one implementation, and to every member List<T> may gain later.

For parameters the logic inverts. Ask for the weakest type the method body can work with, so the widest range of arguments is accepted. A method that only iterates should take IEnumerable<T>, even if every caller today passes a List<T>.

IEnumerable<T>ICollection<T>IList<T>List<T>
KindInterfaceInterfaceInterfaceClass
Adds over the one beforeCount, Add(), Remove(), Clear(), Contains(), CopyTo(), IsReadOnlyindexer, Insert(), RemoveAt(), IndexOf()Sort(), AddRange(), BinarySearch(), Capacity
Size known without enumeratingNoYesYesYes
Add and removeNoYesYesYes
Access by indexNoNoYesYes
Can be lazy or deferredYes, it may be a query that has not runIn practice no — implementations are materialisedIn practice noNo
Safe to enumerate twiceNot guaranteedMaterialised, so repeatableMaterialised, so repeatableYes
Read-only counterpartIReadOnlyCollection<T>IReadOnlyList<T>
Accept as a parameter whenThe method only iterates, onceThe method needs the count, or adds/removesThe method needs indexingAlmost never
Return it whenResults are streamed or lazily producedThe caller is meant to mutate our collectionThe caller is meant to index our collectionNever, from a public API

Considering all these points, we can have a well-chosen type for our specific use case.

Two neighbouring families answer questions this one does not. When the guarantee has to survive the caller, immutable collections enforce it in the type rather than by convention, and when several threads share one collection, the concurrent collections are the right starting point.

Conclusion

That’s all for today and we hope that this article differentiates the characteristics and usage of these collection interfaces.

Tested with .NET 10.0.10.