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.

Creating Indexers

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

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

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

Indexing on Other Values

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;

Tip

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.

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

Tip

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

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 “Generic Lists: List<T>” later in this chapter)

IDictionary<K,V>

Used for key/value-based collections (see “Dictionaries” later in this chapter)

The IEnumerable<T> Interface

You can support the foreach statement in ListBoxTest by implementing the IEnumerable<T> interface.

Tip

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

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset