The goal in creating your own collections is to make them as similar to the standard .NET collections as possible. This reduces confusion, and makes for easier-to-use classes and easier-to-maintain code.
One feature you should provide is to allow users of your collection to add to or extract from the collection with an indexer, just as you would do with an array.
For example, suppose you create a ListBox
control named myListBox
that contains a list of strings stored in a one-dimensional array, a private member variable named myStrings
. A ListBox
control contains member properties and methods in addition to its array of strings, so the ListBox
itself is not an array. However, it would be convenient to be able to access the ListBox
array with an index, just as though the ListBox
itself were an array.[4] For example, such a property would let you do things like this:
string theFirstString = myListBox[0]; string theLastString = myListBox[Length-1];
An indexer is a C# construct that allows you to treat a class as though it were an array. In the preceding example, you are treating the ListBox
as though it were an array of strings, even though it is more than that. An indexer is a special kind of property, but like all properties, it includes get
and set
accessors to specify its behavior.
You declare an indexer property within a class using the following syntax:
type
this
[type argument
]{get; set;}
For example:
public string this[int index] { get {...}; set {...}; }
The return type determines the type of object that will be returned by the indexer, and the type argument specifies what kind of argument will be used to index into the collection that contains the target objects. Although it is common to use integers as index values, you can index a collection on other types as well, including strings. You can even provide an indexer with multiple parameters to create a multidimensional array.
The this
keyword is a reference to the object in which the indexer appears. As with a normal property, you also must define get
and set
accessors, which determine how the requested object is retrieved from or assigned to its collection.
Example 14-1 declares a ListBox
control (ListBoxTest
) that contains a simple array (myStrings
) and a simple indexer for accessing its contents.
Example 14-1. Creating a simple indexer is very similar to creating a property
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Example_14_1_ _ _ _Simple_Indexer { // a simplified ListBox control public class ListBoxTest { private string[] strings; private int ctr = 0; // initialize the ListBox with strings public ListBoxTest( params string[] initialStrings ) { // allocate space for the strings strings = new String[256]; // copy the strings passed in to the constructor foreach ( string s in initialStrings ) { strings[ctr++] = s; } } // add a single string to the end of the ListBox public void Add( string theString ) { if ( ctr >= strings.Length ) { // handle bad index } else strings[ctr++] = theString; } // allow array-like access public string this[int index] { get { if ( index < 0 || index >= strings.Length ) { // handle bad index } return strings[index]; } set { // add new items only through the Add method if ( index >= ctr ) { // handle error } else { strings[index] = value; } } } // publish how many strings you hold public int GetNumEntries( ) { return ctr; } } public class Tester { static void Main( ) { // create a new ListBox and initialize ListBoxTest lbt = new ListBoxTest( "Hello", "World" ); // add a few strings lbt.Add( "Proust" ); lbt.Add( "Faulkner" ); lbt.Add( "Mann" ); lbt.Add( "Hugo" ); // test the access string subst = "Universe"; lbt[1] = subst; // access all the strings for ( int i = 0; i < lbt.GetNumEntries( ); i++ ) { Console.WriteLine( "lbt[{0}]: {1}", i, lbt[i] ); } } } }
lbt[0]: Hello lbt[1]: Universe lbt[2]: Proust lbt[3]: Faulkner lbt[4]: Mann lbt[5]: Hugo
To keep Example 14-1 simple, we’ve stripped the ListBox
control down to the few features we care about. The listing ignores everything else a ListBox
can do, and focuses only on the list of strings the ListBox
maintains, and methods for manipulating them. In a real application, of course, these are a small fraction of the total methods of a ListBox
, whose principal job is to display the strings and enable user choice.
The first things to notice in this example are the two private members:
private string[] strings; private int ctr = 0;
The ListBox
maintains a simple array of strings, cleverly named strings
. The member variable ctr
will keep track of how many strings have been added to this array. Initialize the array in the constructor with the statement:
strings = new string[256];
The Add( )
method of ListBoxTest
does nothing more than append a new string to its internal array (strings
), though a more complex object might write the strings to a database or other more complex data structure. The Add( )
method also increments the counter, so the class has a reliable count of how many strings it holds.
The key item in ListBoxTest
is the indexer. An indexer uses the this
keyword:
public string this[int index]
The syntax of the indexer is very similar to that for properties. There is either a get
accessor, a set
accessor, or both. In the case shown, the get
accessor endeavors to implement rudimentary bounds checking, and assuming the index requested is acceptable, it returns the value requested:
get { if (index < 0 || index >= strings.Length) { // handle bad index } return strings[index]; }
How you handle a bad index is up to you. For the purposes of this example, we’ll assume there aren’t any. However, you’ll see how to deal with these sorts of errors in Chapter 16.
The set
accessor checks to make sure that the index you are setting already has a value in the ListBox
. If not, it treats the set as an error. The way this class is set up, you can add new elements only with the Add( )
method, so it’s illegal to try to add one with set
. The set
accessor takes advantage of the implicit parameter value
that represents whatever is assigned using the index operator:
set { if (index >= ctr ) { // handle error } else { strings[index] = value; } }
Thus, if you write:
lbt[5] = "Hello World"
the compiler will call the indexer set
accessor on your object and pass in the string Hello World
as an implicit parameter named value
.
In Example 14-1, you cannot assign to an index that does not have a value. Thus, if you write:
lbt[10] = "wow!";
you will trigger the error handler in the set
accessor, which will note that the index you’ve passed in (10
) is larger than the counter (6
).
This code is kept simple, and so we don’t handle any errors, as we mentioned. There are any number of other checks you’d want to make on the value passed in (for example, checking that you were not passed a negative index and that it does not exceed the size of the underlying strings[]
array).
In Main( )
, you create an instance of the ListBoxTest
class named lbt
and pass in two strings as parameters:
ListBoxTest lbt = new ListBoxTest("Hello", "World");
Then, call Add( )
to add four more strings:
// add a few strings lbt.Add( "Proust" ); lbt.Add( "Faulkner" ); lbt.Add( "Mann" ); lbt.Add( "Hugo" );
Before examining the values, you modify the second value (at index 1
):
string subst = "Universe"; lbt[1] = subst;
Finally, you display each value with a loop:
for (int i = 0;i<lbt.GetNumEntries( );i++) { Console.WriteLine("lbt[{0}]: {1}",i,lbt[i]); }
C# does not require that you always use an integer value as the index to a collection. Using integers is simply the most common method, because that makes it easier to iterate over the collection with a for
loop. When you create a custom collection class and create your indexer, you are free to create indexers that index on strings and other types. In fact, you can overload the index value so that a given collection can be indexed, for example, by an integer value and also by a string value, depending on the needs of the client.
Example 14-2 illustrates a string index. The indexer calls FindString( )
, which is a helper method that returns a record based on the value of the string provided. Notice that the overloaded indexer and the indexer from Example 14-1 are able to coexist.
Example 14-2. Overloading an index allows you the flexibility of indexing with an integer, or some other type
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Example_14_2_ _ _ _Overloaded_Indexer { // a simplified ListBox control public class ListBoxTest { private string[] strings; private int ctr = 0; // initialize the ListBox with strings public ListBoxTest(params string[] initialStrings) { // allocate space for the strings strings = new String[256]; // copy the strings passed in to the constructor foreach (string s in initialStrings) { strings[ctr++] = s; } } // add a single string to the end of the ListBox public void Add(string theString) { if (ctr >= strings.Length) { // handle bad index } else { strings[ctr++] = theString; } } // allow array-like access public string this[int index] { get { if (index < 0 || index >= strings.Length) { // handle bad index } return strings[index]; } set { // add only through the add method if (index >= ctr) { // handle error } else { strings[index] = value; } } } private int FindString(string searchString) { for (int i = 0; i < strings.Length; i++) { if (strings[i].StartsWith(searchString)) { return i; } } return -1; } // index on string public string this[string index] { get { if (index.Length == 0) { // handle bad index } return this[FindString(index)]; } set { // no need to check the index here because // find string will handle a bad index value strings[FindString(index)] = value; } } // publish how many strings you hold public int GetNumEntries( ) { return ctr; } } public class Tester { static void Main( ) { // create a new ListBox and initialize ListBoxTest lbt = new ListBoxTest("Hello", "World"); // add a few strings lbt.Add("Proust"); lbt.Add("Faulkner"); lbt.Add("Mann"); lbt.Add("Hugo"); // test the access string subst = "Universe"; lbt[1] = subst; lbt["Hel"] = "GoodBye"; // lbt["xyz"] = "oops"; // access all the strings for (int i = 0; i < lbt.GetNumEntries( ); i++) { Console.WriteLine("lbt[{0}]: {1}", i, lbt[i]); } } } }
The output looks like this:
lbt[0]: GoodBye lbt[1]: Universe lbt[2]: Proust lbt[3]: Faulkner lbt[4]: Mann lbt[5]: Hugo
Example 14-2 is identical to Example 14-1 except for the addition of an overloaded indexer, which can match a string, and the method FindString
, created to support that index.
The FindString
method simply iterates through the strings held in myStrings
until it finds a string that starts with the target string used in the index. We’re using a method of the string class called StartsWith( )
, which, as you might imagine, indicates whether a string starts with a specified substring. You’ll learn more about the string methods in Chapter 15. If found, the FindString
method returns the index of that string; otherwise, it returns the value -1
. If more than one entry meets the criterion, FindString
returns the matching entry with the lowest numerical index; that is, the one that comes first.
You can see in Main( )
that the user passes in a string segment to the index, just as with an integer:
lbt["Hel"] = "GoodBye";
This calls the overloaded index, which does some rudimentary error-checking (in this case, making sure the string passed in has at least one letter) and then passes the value (Hel
) to FindString
. It gets back a numerical index and uses that index to index into myStrings
:
return this[FindString(index)];
The set
value works in the same way:
myStrings[FindString(index)] = value;
The careful reader will note that if the string does not match, a value of -1
is returned, which is then used as an index into myStrings
. This action then generates an exception (System.NullReferenceException
), as you can see by uncommenting the following line in Main( )
:
lbt["xyz"] = "oops";
Again, this is an issue that you would handle in real-world code. We haven’t explained exception handling yet (that’s in Chapter 16), so for the moment you don’t need to worry about it.
The .NET Framework provides standard interfaces for enumerating and comparing collections. These standard interfaces are type-safe, but the type is generic; that is, you can declare an ICollection
of any type by substituting the actual type (int, string
, or Employee
) for the generic type in the interface declaration (<T>
).
For example, if you were creating an interface called IStorable
, but you didn’t know what kinds of objects would be stored, you’d declare the interface like this:
interface IStorable<T> { // method declarations here }
Later on, if you wanted to create a class Document
that implemented IStorable
to store string
s, you’d do it like this:
public class Document : IStorable<String>
replacing T
with the type you want to apply the interface to (in this case, string
).
Shockingly, perhaps, that is all there is to generics. The creator of the class says, in essence, “This applies to some type <T>
to be named later (when the interface or class is used) and the programmer using the interface or collection type replaces <T>
with the actual type (for example, int, string, Employee
, and so on).”
The key generic collection interfaces are listed in Table 14-1. C# also provides nongeneric interfaces (ICollection, IEnumerator
—without the <T>
after them), but we will focus on the generic collections, which should be preferred whenever possible as they are type-safe.
Table 14-1. Generic collection interfaces
You can support the foreach
statement in ListBoxTest
by implementing the IEnumerable<T>
interface.
IEnumerable
has only one method, GetEnumerator( )
, whose job is to return an implementation of IEnumerator<T>
. The C# language provides special help in creating the enumerator, using the new keyword yield
, as demonstrated in Example 14-3 and explained shortly.
Example 14-3. Making a ListBox an enumerable class requires implementing the IEnumerable<T> interface
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Example_14_3_ _ _ _Enumerable_Class { public class ListBoxTest : IEnumerable<String> { private string[] strings; private int ctr = 0; // Enumerable classes return an enumerator public IEnumerator<string> GetEnumerator( ) { foreach (string s in strings) { yield return s; } } // required to fulfill IEnumerable System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator( ) { throw new NotImplementedException( ); } // initialize the ListBox with strings public ListBoxTest(params string[] initialStrings) { // allocate space for the strings strings = new String[256]; // copy the strings passed in to the constructor foreach (string s in initialStrings) { strings[ctr++] = s; } } // add a single string to the end of the ListBox public void Add(string theString) { strings[ctr] = theString; ctr++; } // allow array-like access public string this[int index] { get { if (index < 0 || index >= strings.Length) { // handle bad index } return strings[index]; } set { strings[index] = value; } } // publish how many strings you hold public int GetNumEntries( ) { return ctr; } } public class Tester { static void Main( ) { // create a new ListBox and initialize ListBoxTest lbt = new ListBoxTest("Hello", "World"); // add a few strings lbt.Add("Proust"); lbt.Add("Faulkner"); lbt.Add("Mann"); lbt.Add("Hugo"); // test the access string subst = "Universe"; lbt[1] = subst; // access all the strings foreach (string s in lbt) { if (s == null) { break; } Console.WriteLine("Value: {0}", s); } } } }
The output looks like this:
Value: Hello Value: Universe Value: Proust Value: Faulkner Value: Mann Value: Hugo
The program begins in Main( )
, creating a new ListBoxTest
object and passing two strings to the constructor. When the object is created, an array of Strings
is created with enough room for 256 strings. Four more strings are added using the Add
method, and the second string is updated, just as in the previous example.
The big change in this version of the program is that a foreach
loop is called, retrieving each string in the ListBox
. The foreach
loop looks very simple, and it’s supposed to, but it’s actually much more complicated behind the scenes. For a foreach
loop to work properly, it needs a reference to an IEnumerator<T>
(which is, remember, not an object itself, but an object that implements IEnumerator<T>
). However, you don’t need to worry about how to create an enumerator, because of the IEnumerable<T>
interface. IEnumerable<T>
has just one method, GetEnumerator( )
, which returns a reference to an IEnumerator<T>
. (Remember that IEnumerable
and IEnumerator
are not the same things.)
The foreach
loop automatically uses the IEnumerable<T>
interface, invoking GetEnumerator( )
.
The GetEnumerator
method near the top of the class is declared to return an IEnumerator
of type string
:
public IEnumerator
<string> GetEnumerator( )
The implementation iterates through the array of strings, yielding each in turn:
foreach ( string s in strings ) { yield return s; }
It doesn’t look like this method returns an IEnumerator
, but it does, and that’s because of yield
. The keyword yield
is used here explicitly to return a value to the enumerator object. By using the yield
keyword, all the bookkeeping for keeping track of which element is next, resetting the iterator, and so forth is provided for you by the framework, so you don’t need to worry about it.
The method we just showed you is for the generic IEnumerator<T>
interface. Note that our implementation also includes an implementation of the nongeneric GetEnumerator( )
method. This is required by the definition of the generic IEnumerable<T>
. Even though it’s required to be there, you won’t use it, and so it’s typically defined to just throw an exception, since you don’t expect to call it:
// required to fulfill IEnumerable System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator( ) { throw new NotImplementedException( ); }
Again, we’ll explain exceptions in Chapter 16, but this is basically just a way of saying, “Don’t use this method. If you do use this method, something has gone wrong.”
The difference between Examples Example 14-3 and Example 14-2 is just the foreach
loop, but that small difference means that your ListBoxTest
class in Example 14-3 needs to implement IEnumerable<T>
, which means it has to implement both the generic and the nongeneric versions of GetEnumerator( )
.
As you can see, you need a lot of “plumbing” to make foreach
work, and it may not seem like it’s worth it. The framework collections, though, all do support foreach
, and all that plumbing is hidden from you, making it appear like a very simple loop. If you want your collection to work like the framework collections (and you do, right?), you’ll need to support foreach
as well. Fortunately, the IEnumerable<T>
interface and the yield
keyword do a lot of the work for you, and you can use them without knowing exactly what they do. (If you want to find out, though, you can check out the Microsoft Developer Network at http://msdn2.microsoft.com for more detail than you ever wanted.)
[4] The actual ListBox control provided by both Windows Forms and ASP.NET has a collection called Items
that is a collection, and it is the Items
collection that implements the indexer.