14.3. Creating Your Own CollectionsThe 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 may want to provide is to allow users of your collection to add to or extract from the collection with an indexer, just as is done with arrays. 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 if the ListBox itself were an array. [*] For example, such a property would permit statements such as the following:
string theFirstString = myListBox[0]; string theLastString = myListBox[Length-1]; An indexer is a C# construct that allows you to treat a class as if it were an array. In the preceding example, you are treating the ListBox as if 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;} The return type determines the type of object that will be returned by the indexer, while 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. Using a simple indexer
The output looks like this: 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 strip the ListBox control down to the few features we care about. The listing ignores everything having to do with being a user control 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; Our 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 key method of 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 g et( ) method, a set( ) method, or both. In the case shown, the get( ) method 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]; }
The set( ) method 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. (New elements can only be added using Add with this approach.) 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( ) method on your object and pass in the string Hello World as an implicit parameter named value . 14.3.1. Indexers and AssignmentIn Example 14-1, you cannot assign to an index that does not have a value. Thus, if you write: lbt[10] = "wow!"; you would trigger the error handler in the set( ) method, which would note that the index you've passed in ( 10 ) is larger than the counter ( 6 ).
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, modify the second value (at index 1 ): string subst = "Universe"; lbt[1] = subst; Finally, display each value in a loop: for (int i = 0;i<lbt.GetNumEntries( );i++) { Console.WriteLine("lbt[{0}]: {1}",i,lbt[i]); } 14.3.2. Indexing on Other ValuesC# does not require that you always use an integer value as the index to a collection. 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, the index value can be overloaded 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
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. If found, it returns the index of that string; otherwise , it returns the value -1 . 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 an 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;
14.3.3. Generic Collection InterfacesThe .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 kind of objects would be stored, you'd declare the interface like this: interface IStorable<T> { // implementation here } Later on, if you wanted to create a class Document that implemented IStorable to store strings , 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 ).
The key generic collection interfaces are listed in Table 14-1. [*]
Table 14-1. Generic collection interfaces
14.3.4. The IEnumerable<T> InterfaceYou can support the foreach statement in ListBoxTest by implementing the IEnu-merable<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 below. Example 14-3. Making a ListBox an enumerable class
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 automatically uses the IEnumerable<T> interface, invoking GetEnumerator( ) . The GetEnumerator method 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; } The new 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. Note that our implementation includes an implementation of the non-generic Get-Enumerator method. This is required by the definition of the generic IEnumerable and is typically defined to just throw an exception, since we don't expect to call it: // required to fulfill IEnumerable System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator( ) { throw new NotImplementedException( ); } |