Section 14.3. Creating Your Own Collections


14.3. Creating Your Own Collections

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 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:

[*] The actual ListBox control provided by both Windows forms and ASP.NET has a collection called Items, and it is the Items collection that implements the indexer.

 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
 using System; namespace SimpleIndexer {    // a simplified ListBox control    public class ListBoxTest    {       private string[] strings;       private int ctr = 0;       // initialize the list box 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 list box       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;          }       }       // publish how many strings you hold       public int GetNumEntries(  )       {          return ctr;       }    }    public class Tester    {       static void Main(  )       {          // create a new list box 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] );          }       }    } } 

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];     } 

How you handle a bad index is up to you. A common approach is to throw an exception; see the help files for information on the "ArgumentOutOfRange" exception.


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 Assignment

In 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 ).

This code is kept simple, and thus is not robust. There are any number of other checks you'll want to make on the value passed in (e.g., 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, 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 Values

C# 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
 using System; namespace OverloadedIndexer {    // a simplified ListBox control    public class  ListBoxTest  {       private string[] strings;       private int ctr = 0;       // initialize the list box 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 list box       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 list box 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] );          }      // end for       }         // end main    }            // end tester } 

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; 

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"; 

The proper handling of not finding a string is, as they say, left as an exercise for the reader. You might consider displaying an error message or otherwise allowing the user to recover from the error.


14.3.3. Generic Collection Interfaces

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 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 ).

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 (e.g., int , string , Employee , etc.)."


The key generic collection interfaces are listed in Table 14-1. [*]

[*] C# also provides nongeneric interfaces ( ICollection , IEnumerator ), 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

Interface

Purpose

ICollection<T>

Base interface for generic collections

IEnumerator<T>

IEnumerable<T>

Required for collections that will be enumerated with foreach

IComparer<T>

IComparable<T>

Required for collections that will be sorted

IList<T>

Used by indexable collections (see the section "Generic Lists: List<T>")

IDictionary<K,V>

Used for key/value-based collections (see the section "Dictionaries")


14.3.4. The IEnumerable<T> Interface

You can support the foreach statement in ListBoxTest by implementing the IEnu-merable<T> interface.

You read this as "IEnumerable of <T>" or "the generic interface IEnumerable."


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
 using System; using System.Collections.Generic;  // for the generic classes namespace Enumerable {    public class ListBoxTest : IEnumerable<String>    {       private string[] strings;       private int ctr = 0;       // Enumerable classes can 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 list box 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 list box       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 list box 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 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(  );      }  



Learning C# 2005
Learning C# 2005: Get Started with C# 2.0 and .NET Programming (2nd Edition)
ISBN: 0596102097
EAN: 2147483647
Year: 2004
Pages: 250

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net