Practical .NET

Creating a Simple Collection Class

Here's the simplest possible code for creating a custom collection class in the Microsoft .NET Framework that includes support for For…Each loops, an indexer method and initializing collections with {…}.

Both Eric Vogel and I have done articles on creating custom collection classes (specifically, read-only collections in those examples). But the truth is that there are so many collections built into the Microsoft .NET Framework that you'll probably never need to create one of your own.

However, the key word in that last sentence is "probably" -- because sometimes you do need a collection with specific features. It's not difficult, for example, to imagine a collection of audit log entries that developers can add to but from which are not allowed to remove items. Alternatively, you might need a collection of task items that allows the application to remove tasks as they're completed, but doesn't allow adding tasks (that's the responsibility of management).

When you create a custom collection you'll want to do it with the last effort, and that's what this column is about: What's the minimum code you need to create a collection with whatever arbitrary set of features you need? Creating classes is easy to do in the .NET Framework. Creating a collection class with only the combination of features you want to provide should be just as easy. And it almost is: It turns out that the minimum code required to define a collection with any arbitrary set of features you want to provide isn't very much code at all -- about 12 to 14 lines.

No matter what limited set of features you intend to provide, if you're building a collection there are some features that you must provide. At the very least, for example, your collection will need to support processing all of its items with a For…Each loop. In addition, it's very unusual a collection doesn't support retrieving individual items in the collection by position (an indexer). In practice, if you don't supply those two features, then developers might not regard what you've created as a collection at all.

However, after supplying those two features, any additional features you care to provide are up to you. Some features you might care to add will lead to other features, though. For example, if you're going to support adding items to your collection, then you'll also want to support initializers (adding multiple items to the collection using curly braces ( {…}).

Selecting Features
Typically, collections are implemented by implementing some sort of interface (such as IEnumerable) or inheriting from some sort of base class (such as ReadOnlyCollectionBase). Using an interface will force you to add more code than you probably need and implementing a base class might give you more functionality than you need.

Rather than implementing an interface or inherit from an object, you can simply wrap some code around a native .NET collection that has the features you want … or, rather, more features than you want. Once you've picked your collection, you can have your class expose whatever features you want to make available.

For this example, I'll use a List of Customer objects:

Public Class CustomerCollection
  Private CustList As New List(of Customer)

Most collections will allow the user to add new items to the collection. Obviously, the easiest way of doing that is to expose my private List's Add method through a method of your own, as this code does:

Public Sub Add(cust As Customer) 
  CustList.Add(cust);
End Sub

There's a side effect of creating an Add method that accepts the type used by the class' private List: A developer can now load items into my collection using an initializer. In Visual Basic, a developer could use code like this to add two customers to a new instance of my CustomerCollection:

Dim cList As New CustomerCollection From {New Customer("A123"), New Customer("B246") }

If I want to provide a method for removing items from the list, I do that by just exposing my private List's Remove method:

Public Function Remove(cust As Customer) As Boolean
  Return CustList.Remove(cust);
End Function

The complete absence of any error handling code in these methods isn't an accident, by the way. If the user does something with the Remove method that isn't permitted, I'll let my private List generate the error message. In addition to requiring less code, this tactic also ensures that my collection returns the standard message for the error for the .NET Framework.

Required Features
Now that you've exposed the features you want your collection to have, you must support the required features: Processing with For…Each loops and accessing items by position.

In theory, you should add the IEnumerable interface to your class to signal to the .NET Framework that your class supports For…Each loops. But you don't have to do that -- as long as your class has a method called GetEnumerator that returns an object that implements the IEnumerator interface, then the .NET Framework will support processing your collection with a For…Each loop.

Of course, processing my CustomerCollection with a For…Each loop really means processing my private List. My private List, of course, does implement the IEnumerable interface, as most .NET collections do. As a result, the List class has a method called GetEnumerator. That method returns a class that implements IEnumerator and provides everything a For…Each loop needs to iterate through my private list. So, to support For…Each processing, I just need to add a GetEnumerator method to my class that calls the List's GetEnumerator method and returns the result:

Public Function GetEnumerator() As IEnumerator(Of Customer)
  Return custs.GetEnumerator
End Function

Supporting Indexers and Selected Items
Developers will also expect to be able to use an indexer to retrieve items from the collection with code like this:

Dim cust As Customer
Dim cList As New CustomerCollection From {New Customer("A123"), New Customer("B246") }
cust = custs(0)

Adding an indexer to a class is different between C# and Visual Basic, so I'll discuss them separately.

In Visual Basic, my first step in providing an indexer is to create an Item property. While I can, in fact, call the property anything I want, developers expect an indexer property to be called Item. Unlike most properties, your indexer property must accept a parameter representing the index of the position in the collection of the item as the parameter to this method. To make the property look like an indexer (which only requires parentheses) I add the Default keyword to the property: The Default keyword allows the developer to use the property without providing the property's name -- the developer only needs to provide the property's parameter.

For a minimal implementation, I make the property read-only to give this code:

Default ReadOnly Public Property Item(index As Integer)
  Get
    Return CustList(index)
  End Get
End Property

If you extend the property to include a setter, then you should use the setter to replace the object at the position specified by the parameter:

Default Public Property Item(index As Integer)
  Get
    Return CustList(index)
  End Get
  Set (value As Customer)
    CustList(index) = value
  End Get
End Property

By the way, developers can also call the Item method directly, if they prefer.

In C#, you also add a property, but unlike Visual Basic, you must give the property a specific name: this. Like the Visual Basic version, though, the property must accept a parameter (the setter and getter are also very similar):

public Customer this[int i]
{
  get
  {
    return CustList[i];
  }
  set
  {
    CustList[i] = value;
  }
}

Regardless of which language you use, developers can now access items in your collection by position:

cust = custs(0)
custs(0) = cust;

A custom feature that's closely related to indexers is a selected item. The selected item feature allows code in one part of your application to specify an item in the collection and code in another part of the application to access that item through a SelectedItem property. To support a SelectedItem property all you have to do is define the property and extend the indexer's setter to update the property:

Public Property SelectedItem As Customer
Default Public Property Item(index As Integer)
  Get
    SelectedItem = CustList(index)
    Return SelectedItem
  End Get

Using Your Collection
And, with that code, you've created a minimal collection with whatever features from the private collection you'd like to expose. Using your collection when creating other classes is as simple as using any other collection. Here's a Product class that uses my CustomerCollection:

Pubic Class Product
  Public Property Id As Integer
  Public Property Customers As New CustomerCollection
  ...

If you do need your collection in one application it's not impossible that you'll need your collection somewhere else, someday. It's worthwhile, therefore, to do two other things when creating your collection. First, create your collection in a separate class library from the project, so that you can use your collection in other projects.

Second, recognize that what will change from one use of your collection to another will be the class the collection is managing. It makes sense, therefore, to let the calling code specify the object the collection will work with by turning your collection into a generic class. Listing 1 is what the code for a minimal "add-only" collection looks like after that conversion.

Listing 1: Code for Minimal "Add-Only" Collection After Conversion
Public Class AddOnlyCollection(Of T)
  Private CustList As New List(Of T)

  Public Sub Add(cust As T)
    CustList.Add(cust)
  End Sub

  Default ReadOnly Public Property Item(index As Integer) As T
    Get     
      Return CustList(index)
    End Get    
  End Property

  Public Function GetEnumerator() As IEnumerator(Of T)
    Return CustList.GetEnumerator
  End Function

End Class

A developer using this class would write code like this:

Pubic Class Product
  Public Property Id As Integer
  Public Property Customers As New AddOnlyCollection(of Customer)
  ...

And now you have an AddOnlyCollection that will work with any class. You'll probably never need my AddOnlyCollection, but, when you do need to create a collection with some particular set of features, you can do it in about a dozen lines of code.

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus

Featured

Subscribe on YouTube