Discovering the Power of Collections in .NET with Categorization with Examples in C#
--
There are lots of ways to manage collections of objects in programming. In .NET, you have access to a set of standard collection types for storing and manipulating data.
There are resizable lists, linked lists, sorted and unsorted dictionaries, and arrays. Arrays are part of the C# language, but the other types of collection are classes you can instantiate. Using my experience, I’m going to explore the different collection types provided by .NET and how you can use them effectively in your C# applications.
.NET programmers need to know how collection types are categorized.
- Let’s start with interfaces that set the standard for how collections should work.
- Next, you’ve got these ready-to-use collection classes like lists and dictionaries.
- Finally, you’ve got base classes for building custom collections that do exactly what you want. Picking the right collection type for your code starts with knowing these categories.
Enumeration
Generally, you can use all kinds of collections when you’re developing. Some are super simple, like arrays or linked lists, and some are more complicated, like red/black trees and hashtables. I don’t care which one you pick, being able to look inside is pretty important.
Fortunately, .NET BCL has us covered with IEnumerable and IEnumerator (plus their fancier generic versions). These “bad boys” are part of a whole family of collection interfaces (see Figure 2).
IEnumerable and IEnumerator Overview
In IEnumerator, elements in a collection are traversed forward-only. Here’s its declaration:
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}
The MoveNext method moves the current element or “cursor” to the next position, and returns false if there are no more elements. Usually, current returns the element at the current position, which is cast from an object to a more specific type. Before retrieving the first element, MoveNext must be called.
The Reset method in some collections resets the collection to the beginning so that it can be enumerated again. Enumerators aren’t usually implemented in collections. IEnumerable provides enumerators instead.
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
IEnumerable makes it easy to iterate collections. By defining a single method that returns an enumerator, you can delegate the iteration logic to another class. Multiple consumers can enumerate the collection simultaneously without interfering.
Since IEnumerable is the most basic interface for collection classes, you can think of it as an IEnumeratorProvider. For developers who want to optimize their code and streamline their collection management, this is an essential tool.
Let’s see the code example:
namespace AdmirLiveDotNetCollection
{
internal static class Program
{
private static void Main(string[] args)
{
const string cms = "Penzle";
// Since the string class implements IEnumerable, we can use it.
IEnumerator iterator = cms.GetEnumerator();
while (iterator.MoveNext())
{
var c = (char) iterator.Current;
Console.Write ($"{c}.");
}
}
}
}
In C#, you can call methods on enumerators directly with the foreach statement. Here’s the same example using foreach:
namespace AdmirLiveDotNetCollection
{
internal static class Program
{
private static void Main(string[] args)
{
const string cms = "Penzle";
foreach (var charter in cms)
{
Console.Write ($"{charter}.");
}
}
}
}
IEnumerable<T> and IEnumerator<T>
In C#, you’ll almost always see IEnumerator and IEnumerable hanging out with their fancy generic counterparts.
namespace System.Collections.Generic
{
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
T Current { get; }
}
}
namespace System.Collections.Generic
{
public interface IEnumerable<out T> : IEnumerable
{
new IEnumerator<T> GetEnumerator();
}
}
Static type safety is improved with typed versions of Current and GetEnumerator. Consumers like them because they don’t have to box value elements.
Additionally, arrays inherently implement the IEnumerable<T> interface, where T is the array member type.
You’ll get a compile-time error if you pass an array of characters. Code with typed interfaces is more reliable and accurate.
It’s a standard practice for collection classes to publicly expose IEnumerable<T> while “hiding” the non generic IEnumerable through explicit interface implementation.
IEnumerable<T> and IDisposable
You might already know that Enumerable<T> and IEnumerator<T> are pretty cool because IEnumerator<T> actually inherits from IDisposable. Enumerators that hold onto resources, like database connections, can release them when the enumeration is done (or if it gets interrupted).
What’s the coolest thing about it? Foreach handles all that cleanup stuff automatically!
So, you might be wondering — with the awesome type safety provided by generic collection interfaces like IEnumerable<T>, do you ever even need to bother with the non-generic versions (like IEnumerable, ICollection, or IList)? Well, in the case of IEnumerable, you do actually need to implement it alongside IEnumerable<T> because the latter one inherits from it.
But honestly, based on my own experience, it’s pretty rare that you’ll ever need to implement these interfaces from scratch — usually, you can just use iterator methods, Collection<T>, and LINQ to get the job done in a higher-level way.
As a consumer, you can pretty much always stick with the generic interfaces too. But sometimes the non-generic interfaces do come in handy, because they provide a way to unify collections of all element types.
Example of Implementing the Enumeration Interfaces
You might want to implement IEnumerable or IEnumerable<T> for one or more of the following reasons:
- In order to support foreach statement
- Standard collections are required for interoperability
- For a more sophisticated collection interface
You can get an enumerator from another collection by calling GetEnumerator on the inner collection. It only works for the most basic scenarios where you need exactly what’s in the inner collection. C#’s yield return statement lets you write an iterator for more flexibility.
Iterators make it super easy to write collections, like foreach makes it easy to consume collections.
What’s the best part? IEnumerable and IEnumerator (or their generic versions) are automatically implemented by iterators.
Here’s a simple example:
namespace AdmirLiveDotNetCollection
{
public sealed class PenzleCollection : IEnumerable
{
private readonly IReadOnlyList<string> features = new []{ "SaaS Headless CMS", "Penzle Hybrid CMS", "Marketing & Promotions", "Penzle Forms" };
public IEnumerator GetEnumerator()
{
foreach (var feature in features)
{
yield return feature;
}
}
}
internal static class Program
{
private static void Main(string[] args)
{
var penzleCollection = new PenzleCollection();
foreach (var feature in penzleCollection)
{
Console.WriteLine(feature);
}
}
}
}
The GetEnumerator function behaves mystically in the context of iterators. At first glance, it might not seem like it returns an enumerator, but the compiler actually generates a hidden nested enumerator class. The refactored GetEnumerator function instantiates and returns this class.
Iterators are powerful and straightforward tools for iterating over collections, despite their apparent complexity. For developers working with large datasets or complex collections, they’re an essential tool.
As we mentioned before the interface IEnumerator<T> is a fundamental that enables the traversal of collections. It provides a way to iterate over a group of items and perform operations on each item in turn.
However, there is more to IEnumerator<T> than meets the eye. The interface inherits from IDisposable, which allows enumerators to hold references to resources such as database connections. This ensures that those resources are released when enumeration is complete or abandoned partway through.
The beauty of IEnumerator<T> lies in its seamless integration with the foreach statement. Foreach automatically recognizes this detail and converts it into efficient code, simplifying the development process. Foreach takes care of all the low-level stuff, so we don’t have to worry about it.
In practical terms, IEnumerator<T> and the foreach statement can be used to traverse various types of collections, including arrays, lists, and dictionaries.
We will now return to the C# example. To demonstrate the IDisposable interface with the using statement, we will refactor existing code.
public sealed class PenzleCollection : IEnumerable<string>
{
private readonly IReadOnlyList<string> features = new []{ "SaaS Headless CMS", "Penzle Hybrid CMS", "Marketing & Promotions", "Penzle Forms" };
public IEnumerator<string> GetEnumerator()
{
return features.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
internal static class Program
{
private static void Main(string[] args)
{
var penzleCollection = new PenzleCollection();
using var enumerator = penzleCollection.GetEnumerator();
while (enumerator.MoveNext())
{
var feature = enumerator.Current;
System.Console.WriteLine(feature);
}
}
}
As a result, generic collection interfaces like IEnumerable<T> provide extra type safety, making non generic interfaces unnecessary. It’s rare to build non-generic interfaces from scratch. Consumers usually just need generic interfaces, but non generic interfaces can still be useful for unifying collections.
What’s the right interface for you?
.NET’s enumeration interfaces only allow forward-only iteration when working with collections. There are ICollection, IList, and IDictionary interfaces in .NET that offer more functionality like determining a collection’s size, accessing specific members by index, searching, and modifying collections.
There are generic and non-generic versions of these interfaces, so you can use them however you want. Developers can use these interfaces to manage and manipulate collections in .NET more easily.
For example:
- IEnumerable<T> and IEnumerable: Offer minimum functionality let’s say enumeration only.
- ICollection<T> and ICollection: They offer medium functionality for example thy provide the Count property.
- IList<T> and IDictionary<K,V>: They provide maximum functionality such as random access by index or key.
.NET interfaces, especially ICollection, have differences that go beyond what you’d expect. Since generics were developed later, the generic interfaces were designed with hindsight, so they were better.
Therefore, ICollection<T> does not extend ICollection, IList<T> does not extend IList, and IDictionary<TKey, TValue> does not extend IDictionary. The collection classes can still implement both versions of an interface if it’s beneficial.
The reason why IList<T> doesn’t extend IList is that it would cause static type safety problems. If IList<T> extended IList, it could add objects of any type, which could be a problem. This means that if we cast to IList<T>, we could accidentally call Add with the wrong type of object, causing errors or other issues.
The terms collection and list are used differently in .NET and there is no consistent pattern. For example, you might expect the List<T> class to have more functionality than the Collection<T> class because IList<T> is a more functional version of ICollection<T>, but this is not always the case.
It’s best to think of the terms collection and list as being mostly the same, except when a specific type is involved. This means that when working with .NET libraries, it’s important to pay attention to the specific interfaces and classes being used, rather than relying solely on the terms collection or list.
Finally
In this article, we explored the different types of collections provided by .NET and how to use them effectively in C# applications. We started with the enumeration interfaces, which provide the standard for how collections should work in .NET. We also looked at the generic versions of these interfaces, which offer improved static type safety.
We then explored the different levels of functionality offered by collection interfaces like IEnumerable, ICollection, IList, and IDictionary. We learned that the generic interfaces were designed with hindsight, so they offer better functionality than their non-generic counterparts.
Finally, we discussed the differences between the terms collection and list in .NET and how it’s important to pay attention to the specific interfaces and classes being used when working with .NET libraries.
In the next part of this series, we will dive deeper into the ICollection<T> and ICollection interfaces, as well as IReadOnlyCollection<T> and IReadOnlyList<T>, and explain each type in detail. Be sure to stay tuned.
P.S. If you believe I can help with something, don’t hesitate to contact me, and I will reach you as soon as possible. admir.m@penzle.com
Cheers! 👋