IEnumerable, ICollection and IReadOnlyList

Choosing the weakest collection interface that does the job, and knowing exactly what each one promises a caller.

Syntaxstatic int Total(IEnumerable<int> values)

An interface is a list of members a type promises to provide, written down with no code behind it. A type that provides them can be used anywhere that interface is asked for. You write your own later in the course; here you only need the ones the built-in collections already provide.

What each one promises

InterfacePromises
IEnumerable<T>You can walk through the items, forwards, with foreach. Nothing else: no count, no positions.
IReadOnlyCollection<T>Everything above, plus Count.
IReadOnlyList<T>Everything above, plus reading by position with items[i].
ICollection<T>Count, Add, Remove, Contains, Clear and IsReadOnly.

List<T> provides all four, so one list can be passed as any of them.

The rule for parameters

Ask for the weakest interface that does the job. A method that only reads every item should take IEnumerable<T>, because then an array, a List<T>, a HashSet<T> and a dictionary's Values all fit it with no conversion. Ask for ICollection<T> only when you really do add or remove.

The rule for return types

Return the weakest type the caller actually needs. IReadOnlyList<T> is the usual answer for a finished sequence: the caller gets Count and positions, and no way to change your collection. Returning your internal List<T> hands callers the ability to edit it.

Where it goes wrong

IReadOnlyList<T> means the caller cannot change the collection through that reference. It does not mean the data cannot change: whoever holds the original list can still add to it, and the caller sees that.

Example

Example · csharp
List<string> names = new List<string> { "ana", "bo", "cy" };

// Describe only reads, so it asks for the weakest thing it can read:
// IEnumerable<string>. A List and an array both satisfy it.
Console.WriteLine(Describe(names));
Console.WriteLine(Describe(new[] { "dee" }));

// Shout returns IReadOnlyList<string>: Count and positions, no Add or Remove.
IReadOnlyList<string> loud = Shout(names);
Console.WriteLine($"first: {loud[0]}, count: {loud.Count}");

static string Describe(IEnumerable<string> items)
{
    int count = 0;

    foreach (string item in items)
    {
        count++;
    }

    return $"{count} item(s)";
}

static IReadOnlyList<string> Shout(IEnumerable<string> items)
{
    List<string> result = new List<string>();

    foreach (string item in items)
    {
        result.Add(item.ToUpperInvariant());
    }

    return result;   // a List<T> is an IReadOnlyList<T>, so no copy is needed
}

// Output:
// 3 item(s)
// 1 item(s)
// first: ANA, count: 3

When to use it

  • Writing a method that sums or formats items so it accepts an array, a List and a dictionary's Values unchanged.
  • Returning a finished result set as IReadOnlyList<T> so callers cannot add to the collection you kept.
  • Deciding whether a parameter really needs Add, which is usually the sign that it should be a return value instead.

More examples

Reading by position, adding, and the array that cannot

This is the concrete reason for asking for the weakest interface. ICollection<T> promises that an Add method exists, not that it works, so a parameter typed that way quietly accepts arrays that will throw.

Example · csharp
int[] numbers = { 3, 1, 2 };
List<int> list = new List<int> { 3, 1, 2 };

// IReadOnlyList<T> is the weakest interface that gives positions.
Console.WriteLine(Second(numbers));
Console.WriteLine(Second(list));

// ICollection<T> is the weakest one that allows adding.
Fill(list, 4);
Console.WriteLine($"list count: {list.Count}");

// An array provides ICollection<T>, but reports that it is read-only, and
// its Add throws NotSupportedException. Check IsReadOnly before trusting Add.
Console.WriteLine($"array is read-only: {((ICollection<int>)numbers).IsReadOnly}");

static int Second(IReadOnlyList<int> values)
{
    return values[1];
}

static void Fill(ICollection<int> target, int value)
{
    target.Add(value);
}

// Output:
// 1
// 1
// list count: 4
// array is read-only: True

One parameter, many callers

Had Total asked for List<int>, both calls would need a copy at the call site. Taking IEnumerable<T> costs nothing and removes those copies.

Example · csharp
Dictionary<string, int> stock = new Dictionary<string, int>
{
    { "apple", 4 },
    { "pear", 7 }
};

// Values is a collection of ints. Because Total asks only for
// IEnumerable<int>, the same method also takes a List or an array.
Console.WriteLine($"total: {Total(stock.Values)}");
Console.WriteLine($"total: {Total(new int[] { 1, 2 })}");

static int Total(IEnumerable<int> values)
{
    int sum = 0;

    foreach (int value in values)
    {
        sum += value;
    }

    return sum;
}

// Output:
// total: 11
// total: 3

AsReadOnly gives a view, not a copy

Use this when you want to hand out a list that callers must not edit. Remember what it does not give you: if the caller needs a frozen snapshot, copy the items instead, with ToArray or a new List.

Example · csharp
List<string> names = new List<string> { "ana", "bo" };

// AsReadOnly wraps the same list. The caller cannot add through the wrapper,
// but changes to the original list do show up in it.
IReadOnlyList<string> view = names.AsReadOnly();
Console.WriteLine($"view count before: {view.Count}");

names.Add("cy");
Console.WriteLine($"view count after: {view.Count}");
Console.WriteLine($"last: {view[view.Count - 1]}");

// Output:
// view count before: 2
// view count after: 3
// last: cy

Discussion

  • Be the first to comment on this lesson.