If you’re anything like most new .NET developers you’ve probably seen IEnumerable<T>
, IList<T>
, and maybe even ICollection<T>
around. If you’re anything like most people, you’ve probably found these interfaces confusing and been unsure which one to use when.
These interfaces are everywhere. For example, if you were to look at the List<T>
class’s implementation in modern .NET you’d see it implements the following interfaces (among others):
IList<T>
ICollection<T>
IReadOnlyList<T>
IReadOnlyCollection<T>
IEnumerable<T>
Each one of these interfaces has a point, and each one is slightly different. However, many of these interfaces can be intimidating the first time you encounter them.
In this article we’ll explore these common .NET interfaces, what they provide, and when you might choose to use each one.
Note: There are also the older non-generic interfaces of IEnumerable and IList. For the purposes of this article we’ll focus instead on generic collection interfaces since these are more commonly used in modern .NET.
Why not just use List<T>?
When I teach my students about IEnumerable
, I usually see eyes gloss over from the abstractness of the concept and the foreignness of the word enumerable. A modest chunk of my students start panicking and wonder if they can scurry back to the safety of just working with List<T>
and pretend that interfaces aren’t there.
The short answer is this: You can ignore collection interfaces some of the time.
However, sometimes you have to work with these interfaces when others provide them to you, and sometimes it’s better to work with these interfaces instead of concrete types.
To explore this, let’s take a look at this simple example below:
private List<User> _users = new();
public List<User> Users
{
get
{
return _users;
}
}
Here it looks like we have a properly encapsulated List of User objects in the _users
field. However, upon closer examination, there are a few problems:
First, by returning aList<User>
we invite others to store the result of this call in List<User> variables or properties. This is fine for now, but if we ever change the type of collection we’re returning, our callers will need to update as well.
Secondly, by returning aList<User>
, we give calling methods full access to the methods on that List object, including Add
, Remove
, and Sort
. Worse, these methods will work and update the same List
our code is using in its _users field.
This violates encapsulation and may result in inconsistent state within the class due to changes to the internal state of the class coming from outside of the class in unintended ways.
Alternatively, if this didn’t actually modify the class, but the caller still thought they could use the Add
, Remove
, or Sort
methods, they are now confused and frustrated because our design didn’t indicate to them that they couldn’t do this.
While these problems aren’t typically catastrophic, it’d be better to avoid them if we can, which is where .NET collection interfaces come into play.
Common .NET Collection Interfaces
Because a picture can be far more efficient than entire sections, I’ve summarized these five interfaces in this comparison matrix:
In the sections below we’ll explore each one of these five interfaces and when to use each one.
IEnumerable<T>
IEnumerable<T>
is a foundational interface in .NET that simply means that something can be looped over in a foreach
statement.
IEnumerable<T>
is supported by List<T>
, arrays, and almost any other collection type in .NET.
That’s about all you need to know about IEnumerable<T>
, but if you’d like a few more details, read on to learn about IEnumerator<T>
as well.
IEnumerable<T>
internally works by providing a GetEnumerator
method that returns an IEnumerator<T>
object.
This IEnumerator<T>
provides ways to:
- Get the current item (if one is present)
- Move to the next item
- Reset back to the first item
These three capabilities power any collection type that can be looped over.
ICollection<T>
ICollection<T>
allows you to manage collections of items. All ICollection<T>
s are also IEnumerable<T>
.
Using ICollection<T>
you can:
- Get a
Count
of the number of items in a collection - See if a collection
Contains
something - Modify the collection by
Add
,Remove
, andClear
methods - Copy the collection to an array
- Determine if the collection is read only
Notably, ICollection<T>
does not include array indexers in the interface, so you cannot use an indexer to get an item out of a collection by its index.
ICollection<T>
is a great choice if you need to add and remove things from a collection as well as loop over it, but you don’t need to use an indexer.
IList<T>
IList<T>
is an extremely powerful and common ordered collection interface in .NET.
IList<T>
s all implement both ICollection<T>
and IEnumerable<T>
but add the ability to work with indexes.
Specifically, IList<T
> adds the following capabilities:
- A collection indexer that lets you use syntax like int number = myList[0];
- Allows you to find the
IndexOf
an item in the list -
Insert
items at a specific index -
RemoveAt
a specific index
If your collection is ordered and has specific indexes and you want others to take full advantage of them, IList<T>
is a great choice.
IReadOnlyCollection<T>
IReadOnlyCollection<T>
is a rarer interface to see, but it is essentially a version of IEnumerable<T>
that also has a Count of items.
You can also think about IReadOnlyCollection<T>
as a version of Collection<T> that removes ways of modifying the items.
All IReadOnlyCollection<T>
s are also IEnumerable<T>
.
IReadOnlyCollection<T>
is a good choice if you want to return a collection but don’t want to expose ways of manipulating it through the interface.
IReadOnlyList<T>
IReadOnlyList<T>
is an extension of IReadOnlyCollection<T>
but adds an indexer so that you can get values out of the collection by an index. Otherwise, IReadOnlyList<T>
is functionally identical to IReadOnlyCollection<T>
.
Securing against Casting
You may now be thinking that using an IReadOnlyList<T> or IReadOnlyCollection<T> protects you from others modifying your code.
However, that actually may not be fully true.
Let’s revisit our example from earlier, but take advantage of IReadOnlyList<T>:
private List<User> _users = new();
public IReadOnlyList<User> Users
{
get
{
return _users;
}
}
Here the class can use a full List<User> internally but only expose IReadOnlyList<User> to external callers.
This stops callers from doing the following:
// Wouldn't work since Users is an IReadOnlyList
myObject.Users.Add(new User("Matt Eland"));
However, since the actual object being returned above is still a List<User>
, savvy callers may notice this and cast the result to a List<User>
and then interact with it in a way you didn’t intend:
// Cast the result of the Users property to a List<User>
List<User> users = (List<User>)myObject.Users;
// This works
users. Add(new User("Matt Eland"));
Because of this, it’s a good idea to secure the items you intend to be read only by calling either ToList()
or AsReadOnly()
on them using LINQ to create a separate collection when returning values as shown below:
private List<User> _users = new();
public IReadOnlyList<User> Users
{
get
{
return _users.AsReadOnly();
}
}
Conclusion
While it’s certainly possible to write code that just returns Lists or other concrete types, using the appropriate .NET collection interface can help your code shine.
Using the right interface lets others see your intent, helps you properly encapsulate your classes, and gives you flexibility to change collection types in a class without impacting callers.
Top comments (3)
Pretty much agree with everything except the last point. If the list is read-only, instead of creating a new instance of the list using the
AsReadOnly()
LINQ extension method, it would be wiser to simply store and save a ReadOnlyCollection object and provide this same object every time.The ReadOnlyCollection's constructor will take an
IList<T>
list and will actually present to its consumer any changes made to the elements in this underlying list.So in the end you actually get 2 benefits: You spare RAM usage and a few CPU cycles, and you provide a collection that is alive: It reflects the changes made to the underlying data source.
I like this idea; thank you.
Great article, went to my save list.