CHAPTER 10
Collections and Generics
Any application you create with the .NET Core platform will need to contend with the issue of maintaining and manipulating a set of data points in memory. These data points can come from any variety of locations including a relational database, a local text file, an XML document, a web service call, or perhaps user- provided input.
When the .NET platform was first released, programmers frequently used the classes of the System. Collections namespace to store and interact with bits of data used within an application. In .NET 2.0, the C# programming language was enhanced to support a feature termed generics; with this change, a new namespace was introduced in the base class libraries: System.Collections.Generic.
This chapter will provide you with an overview of the various collection (generic and nongeneric) namespaces and types found within the .NET Core base class libraries. As you will see, generic containers are often favored over their nongeneric counterparts because they typically provide greater type safety and performance benefits. After you have learned how to create and manipulate the generic items found in the framework, the remainder of this chapter will examine how to build your own generic methods and generic types. As you do this, you will learn about the role of constraints (and the corresponding C# where keyword), which allow you to build extremely type-safe classes.
The Motivation for Collection Classes
The most primitive container you could use to hold application data is undoubtedly the array. As you saw in Chapter 4, C# arrays allow you to define a set of identically typed items (including an array of System. Objects, which essentially represents an array of any type of data) of a fixed upper limit. Also recall from Chapter 4 that all C# array variables gather a good deal of functionality from the System.Array class. By way of a quick review, consider the following code, which creates an array of textual data and manipulates its contents in various ways:
// Make an array of string data.
string[] strArray = {"First", "Second", "Third" };
// Show number of items in array using Length property. Console.WriteLine("This array has {0} items.", strArray.Length); Console.WriteLine();
// Display contents using enumerator. foreach (string s in strArray)
{
Console.WriteLine("Array Entry: {0}", s);
}
© Andrew Troelsen, Phil Japikse 2022
A. Troelsen and P. Japikse, Pro C# 10 with .NET 6, https://doi.org/10.1007/978-1-4842-7869-7_10
387
Console.WriteLine();
// Reverse the array and print again. Array.Reverse(strArray);
foreach (string s in strArray)
{
Console.WriteLine("Array Entry: {0}", s);
}
Console.ReadLine();
While basic arrays can be useful to manage small amounts of fixed-size data, there are many other times where you require a more flexible data structure, such as a dynamically growing and shrinking container or a container that can hold objects that meet only a specific criterion (e.g., only objects deriving from a specific base class or only objects implementing a particular interface). When you make use of a simple array, always remember they are created with a “fixed size.” If you make an array of three items, you get only three items; therefore, the following code will result in a runtime exception (an IndexOutOfRangeException, to be exact):
// Make an array of string data.
string[] strArray = { "First", "Second", "Third" };
// Try to add a new item at the end?? Runtime error! strArray[3] = "new item?";
...
■ Note It is actually possible to change the size of an array using the generic Resize()
To help overcome the limitations of a simple array, the .NET Core base class libraries ship with a number of namespaces containing collection classes. Unlike a simple C# array, collection classes are built to dynamically resize themselves on the fly as you insert or remove items. Moreover, many of the collection classes offer increased type safety and are highly optimized to process the contained data in a memory-
efficient manner. As you read this chapter, you will quickly notice that a collection class can belong to one of two broad categories.
•Nongeneric collections (primarily found in the System.Collections namespace)
•Generic collections (primarily found in the System.Collections.Generic
namespace)
Nongeneric collections are typically designed to operate on System.Object types and are, therefore, loosely typed containers (however, some nongeneric collections do operate on only a specific type of data, such as string objects). In contrast, generic collections are much more type-safe, given that you must specify the “type of type” they contain upon creation. As you will see, the telltale sign of any generic item is the “type parameter” marked with angled brackets (e.g., List
of the key nongeneric collection types in the System.Collections and System.Collections.Specialized
namespaces.
The System.Collections Namespace
When the .NET platform was first released, programmers frequently used the nongeneric collection classes found within the System.Collections namespace, which contains a set of classes used to manage and organize large amounts of in-memory data. Table 10-1 documents some of the more commonly used collection classes of this namespace and the core interfaces they implement.
Table 10-1. Useful Types of System.Collections
System.Collections
Class Meaning in Life Key Implemented Interfaces
ArrayList Represents a dynamically sized collection of objects listed in sequential order IList, ICollection, IEnumerable, and ICloneable
BitArray Manages a compact array of bit values, which are represented as Booleans, where true indicates that the bit is on (1) and false indicates the bit is off (0) ICollection, IEnumerable, and ICloneable
Hashtable Represents a collection of key-value pairs that are organized based on the hash code of the key IDictionary, ICollection, IEnumerable, and ICloneable
Queue Represents a standard first-in, first-out (FIFO) collection of objects ICollection, IEnumerable, and ICloneable
SortedList Represents a collection of key-value pairs that are sorted by the keys and are accessible by key and by index IDictionary, ICollection, IEnumerable, and ICloneable
Stack A last-in, first-out (LIFO) stack providing push and pop (and peek) functionality ICollection, IEnumerable, and ICloneable
The interfaces implemented by these collection classes provide huge insights into their overall functionality. Table 10-2 documents the overall nature of these key interfaces, some of which you worked with firsthand in Chapter 8.
Table 10-2. Key Interfaces Supported by Classes of System.Collections
System.Collections
Interface Meaning in Life
ICollection Defines general characteristics (e.g., size, enumeration, and thread safety) for all nongeneric collection types
ICloneable Allows the implementing object to return a copy of itself to the caller
IDictionary Allows a nongeneric collection object to represent its contents using key-value pairs
IEnumerable Returns an object implementing the IEnumerator interface (see next table entry)
IEnumerator Enables foreach-style iteration of collection items
IList Provides behavior to add, remove, and index items in a sequential list of objects
An Illustrative Example: Working with the ArrayList
Based on your experience, you might have some firsthand experience using (or implementing) some of these classic data structures such as stacks, queues, and lists. If this is not the case, I will provide some further details on their differences when you examine their generic counterparts a bit later in this chapter. Until then, here is example code using an ArrayList object:
// You must import System.Collections to access the ArrayList. using System.Collections;
ArrayList strArray = new ArrayList();
strArray.AddRange(new string[] { "First", "Second", "Third" });
// Show number of items in ArrayList.
System.Console.WriteLine("This collection has {0} items.", strArray.Count); System.Console.WriteLine();
// Add a new item and display current count. strArray.Add("Fourth!");
System.Console.WriteLine("This collection has {0} items.", strArray.Count);
// Display contents.
foreach (string s in strArray)
{
System.Console.WriteLine("Entry: {0}", s);
}
System.Console.WriteLine();
Notice that you can add (or remove) items on the fly and the container automatically resizes itself accordingly.
As you would guess, the ArrayList class has many useful members beyond the Count property and AddRange() and Add() methods, so be sure you consult the .NET Core documentation for full details. On a related note, the other classes of System.Collections (Stack, Queue, etc.) are also fully documented in the
.NET Core help system.
However, it is important to point out that a majority of your .NET Core projects will most likely not make use of the collection classes in the System.Collections namespace! To be sure, these days it is far more common to make use of the generic counterpart classes found in the System.Collections.Generic namespace. Given this point, I won’t comment on (or provide code examples for) the remaining nongeneric classes found in System.Collections.
A Survey of System.Collections.Specialized Namespace
System.Collections is not the only .NET Core namespace that contains nongeneric collection classes. The System.Collections.Specialized namespace defines a number of (pardon the redundancy) specialized collection types. Table 10-3 documents some of the more useful types in this particular collection-centric namespace, all of which are nongeneric.
Table 10-3. Useful Classes of System.Collections.Specialized
System.Collections.
Specialized Type Meaning in Life
HybridDictionary This class implements IDictionary by using a ListDictionary while the collection is small and then switching to a Hashtable when the collection gets large.
ListDictionary This class is useful when you need to manage a small number of items (ten or so) that can change over time. This class makes use of a singly linked list to maintain its data.
StringCollection This class provides an optimal way to manage large collections of string data.
BitVector32 This class provides a simple structure that stores Boolean values and small integers in 32 bits of memory.
Beyond these concrete class types, this namespace also contains many additional interfaces and abstract base classes that you can use as a starting point for creating custom collection classes. While these “specialized” types might be just what your projects require in some situations, I won’t comment on their usage here. Again, in many cases, you will likely find that the System.Collections.Generic namespace provides classes with similar functionality and additional benefits.
■ Note there are two additional collection-centric namespaces (System.Collections.ObjectModel and System.Collections.Concurrent) in the .net Core base class libraries. You will examine the former namespace later in this chapter, after you are comfortable with the topic of generics. System.Collections. Concurrent provides collection classes well suited to a multithreaded environment (see Chapter 15 for information on multithreading).
The Problems of Nongeneric Collections
While it is true that many successful .NET and .NET Core applications have been built over the years using these nongeneric collection classes (and interfaces), history has shown that using these types can result in a number of issues.
The first issue is that using the System.Collections and System.Collections.Specialized classes can result in some poorly performing code, especially when you are manipulating numerical data (e.g., value types). As you’ll see momentarily, the CoreCLR must perform a number of memory transfer operations when you store structures in any nongeneric collection class prototyped to operate on System.Objects, which can hurt runtime execution speed.
The second issue is that most of the nongeneric collection classes are not type-safe because (again) they were developed to operate on System.Objects, and they could therefore contain anything at all.
If a developer needed to create a highly type-safe collection (e.g., a container that can hold objects implementing only a certain interface), the only real choice was to create a new collection class by hand. Doing so was not too labor intensive, but it was a tad on the tedious side.
Before you look at how to use generics in your programs, you’ll find it helpful to examine the issues of nongeneric collection classes a bit closer; this will help you better understand the problems generics intended to solve in the first place. If you want to follow along, create a new Console Application project
named IssuesWithNonGenericCollections. Next, make sure you import the System.Collections namespace to the top of the Program.cs file and clear out the rest of the code.
using System.Collections;
The Issue of Performance
As you might recall from Chapter 4, the .NET Core platform supports two broad categories of data: value types and reference types. Given that .NET Core defines two major categories of types, you might occasionally need to represent a variable of one category as a variable of the other category. To do so, C#
provides a simple mechanism, termed boxing, to store the data in a value type within a reference variable. Assume that you have created a local variable of type int in a method called SimpleBoxUnboxOperation. If, during the course of your application, you were to represent this value type as a reference type, you would box the value, as follows:
static void SimpleBoxUnboxOperation()
{
// Make a ValueType (int) variable. int myInt = 25;
// Box the int into an object reference. object boxedInt = myInt;
}
Boxing can be formally defined as the process of explicitly assigning a value type to a System.Object variable. When you box a value, the CoreCLR allocates a new object on the heap and copies the value type’s value (25, in this case) into that instance. What is returned to you is a reference to the newly allocated heap- based object.
The opposite operation is also permitted through unboxing. Unboxing is the process of converting the value held in the object reference back into a corresponding value type on the stack. Syntactically speaking, an unboxing operation looks like a normal casting operation. However, the semantics are quite different.
The CoreCLR begins by verifying that the receiving data type is equivalent to the boxed type, and if so, it copies the value back into a local stack-based variable. For example, the following unboxing operations work successfully, given that the underlying type of the boxedInt is indeed an int:
static void SimpleBoxUnboxOperation()
{
// Make a ValueType (int) variable. int myInt = 25;
// Box the int into an object reference. object boxedInt = myInt;
// Unbox the reference back into a corresponding int. int unboxedInt = (int)boxedInt;
}
When the C# compiler encounters boxing/unboxing syntax, it emits CIL code that contains the box/unbox op codes. If you were to examine your compiled assembly using ildasm.exe, you would find the following:
.method assembly hidebysig static
void '<
{
.maxstack 1
.locals init (int32 V_0, object V_1, int32 V_2) IL_0000: nop
IL_0001: ldc.i4.s 25 IL_0003: stloc.0 IL_0004: ldloc.0
IL_0005: box [System.Runtime]System.Int32 IL_000a: stloc.1
IL_000b: ldloc.1
IL_000c: unbox.any [System.Runtime]System.Int32 IL_0011: stloc.2
IL_0012: ret
} // end of method '
Remember that unlike when performing a typical cast, you must unbox into an appropriate data type. If you attempt to unbox a piece of data into the incorrect data type, an InvalidCastException exception will be thrown. To be perfectly safe, you should wrap each unboxing operation in try/catch logic; however, this would be quite labor intensive to do for every unboxing operation. Consider the following code update, which will throw an error because you’re attempting to unbox the boxed int into a long:
static void SimpleBoxUnboxOperation()
{
// Make a ValueType (int) variable. int myInt = 25;
// Box the int into an object reference. object boxedInt = myInt;
// Unbox in the wrong data type to trigger
// runtime exception.
try
{
long unboxedLong = (long)boxedInt;
}
catch (InvalidCastException ex)
{
Console.WriteLine(ex.Message);
}
}
At first glance, boxing/unboxing might seem like a rather uneventful language feature that is more academic than practical. After all, you will seldom need to store a local value type in a local object variable, as shown here. However, it turns out that the boxing/unboxing process is quite helpful because it allows you to assume everything can be treated as a System.Object, while the CoreCLR takes care of the memory- related details on your behalf.
Let’s look at a practical use of these techniques. We will examine the System.Collections.ArrayList class and use it to hold onto a batch of numeric (stack-allocated) data. The relevant members of the ArrayList class are listed as follows. Notice that they are prototyped to operate on System.Object data. Now consider the Add(), Insert(), and Remove() methods, as well as the class indexer.
public class ArrayList : IList, ICloneable
{
...
public virtual int Add(object? value);
public virtual void Insert(int index, object? value); public virtual void Remove(object? obj);
public virtual object? this[int index] {get; set; }
}
ArrayList has been built to operate on objects, which represent data allocated on the heap, so it might seem strange that the following code compiles and executes without throwing an error:
static void WorkWithArrayList()
{
// Value types are automatically boxed when
// passed to a method requesting an object. ArrayList myInts = new ArrayList(); myInts.Add(10);
myInts.Add(20); myInts.Add(35);
}
Although you pass in numerical data directly into methods requiring an object, the runtime automatically boxes the stack-based data on your behalf. Later, if you want to retrieve an item from the ArrayList using the type indexer, you must unbox the heap-allocated object into a stack-allocated integer using a casting operation. Remember that the indexer of the ArrayList is returning System.Objects, not System.Int32s.
static void WorkWithArrayList()
{
// Value types are automatically boxed when
// passed to a member requesting an object. ArrayList myInts = new ArrayList(); myInts.Add(10);
myInts.Add(20); myInts.Add(35);
// Unboxing occurs when an object is converted back to
// stack-based data.
int i = (int)myInts[0];
// Now it is reboxed, as WriteLine() requires object types!
Console.WriteLine("Value of your int: {0}", i);
}
Again, note that the stack-allocated System.Int32 is boxed prior to the call to ArrayList.Add(), so it can be passed in the required System.Object. Also note that the System.Object is unboxed back into a
System.Int32 once it is retrieved from the ArrayList via the casting operation, only to be boxed again when it is passed to the Console.WriteLine() method, as this method is operating on System.Object variables.
Boxing and unboxing are convenient from a programmer’s viewpoint, but this simplified approach to stack/heap memory transfer comes with the baggage of performance issues (in both speed of execution and code size) and a lack of type safety. To understand the performance issues, ponder these steps that must occur to box and unbox a simple integer:
1.A new object must be allocated on the managed heap.
2.The value of the stack-based data must be transferred into that memory location.
3.When unboxed, the value stored on the heap-based object must be transferred back to the stack.
4.The now unused object on the heap will (eventually) be garbage collected.
Although this particular WorkWithArrayList() method won’t cause a major bottleneck in terms of performance, you could certainly feel the impact if an ArrayList contained thousands of integers that your program manipulates on a somewhat regular basis. In an ideal world, you could manipulate stack-based data in a container without any performance issues. Ideally, it would be nice if you did not have to bother plucking data from this container using try/catch scopes (this is exactly what generics let you achieve).
The Issue of Type Safety
I touched on the issue of type safety when covering unboxing operations. Recall that you must unbox your data into the same data type it was declared as before boxing. However, there is another aspect of type safety you must keep in mind in a generic-free world: the fact that a majority of the classes of System.Collections can typically hold anything whatsoever because their members are prototyped to operate on System.
Objects. For example, this method builds an ArrayList of random bits of unrelated data:
static void ArrayListOfRandomObjects()
{
// The ArrayList can hold anything at all. ArrayList allMyObjects = new ArrayList(); allMyObjects.Add(true);
allMyObjects.Add(new OperatingSystem(PlatformID.MacOSX, new Version(10, 0))); allMyObjects.Add(66);
allMyObjects.Add(3.14);
}
In some cases, you will require an extremely flexible container that can hold literally anything (as shown here). However, most of the time you desire a type-safe container that can operate only on a particular type of data point. For example, you might need a container that can hold only database connections, bitmaps, or IPointy-compatible objects.
Prior to generics, the only way you could address this issue of type safety was to create a custom (strongly typed) collection class manually. Assume you want to create a custom collection that can contain only objects of type Person.
namespace IssuesWithNonGenericCollections; public class Person
{
public int Age {get; set;}
public string FirstName {get; set;} public string LastName {get; set;}
public Person(){}
public Person(string firstName, string lastName, int age)
{
Age = age;
FirstName = firstName;
LastName = lastName;
}
public override string ToString()
{
return $"Name: {FirstName} {LastName}, Age: {Age}";
}
}
To build a collection that can hold only Person objects, you could define a System.Collections. ArrayList member variable within a class named PersonCollection and configure all members to operate on strongly typed Person objects, rather than on System.Object types. Here is a simple example (a production-level custom collection could support many additional members and might extend an abstract base class from the System.Collections or System.Collections.Specialized namespace):
using System.Collections;
namespace IssuesWithNonGenericCollections; public class PersonCollection : IEnumerable
{
private ArrayList arPeople = new ArrayList();
// Cast for caller.
public Person GetPerson(int pos) => (Person)arPeople[pos];
// Insert only Person objects.
public void AddPerson(Person p)
{
arPeople.Add(p);
}
public void ClearPeople()
{
arPeople.Clear();
}
public int Count => arPeople.Count;
// Foreach enumeration support.
IEnumerator IEnumerable.GetEnumerator() => arPeople.GetEnumerator();
}
Notice that the PersonCollection class implements the IEnumerable interface, which allows a foreach- like iteration over each contained item. Also notice that your GetPerson() and AddPerson() methods have been prototyped to operate only on Person objects, not bitmaps, strings, database connections, nor other items. With these types defined, you are now assured of type safety, given that the C# compiler will be able
to determine any attempt to insert an incompatible data type. Update the using statements in Program.cs to the following and add the UserPersonCollection() method to the end of your current code:
using System.Collections;
using IssuesWithNonGenericCollections;
//Top level statements in Program.cs static void UsePersonCollection()
{
Console.WriteLine(" Custom Person Collection \n"); PersonCollection myPeople = new PersonCollection(); myPeople.AddPerson(new Person("Homer", "Simpson", 40)); myPeople.AddPerson(new Person("Marge", "Simpson", 38)); myPeople.AddPerson(new Person("Lisa", "Simpson", 9)); myPeople.AddPerson(new Person("Bart", "Simpson", 7)); myPeople.AddPerson(new Person("Maggie", "Simpson", 2));
// This would be a compile-time error!
// myPeople.AddPerson(new Car());
foreach (Person p in myPeople)
{
Console.WriteLine(p);
}
}
While custom collections do ensure type safety, this approach leaves you in a position where you must create an (almost identical) custom collection for each unique data type you want to contain. Thus, if you need a custom collection that can operate only on classes deriving from the Car base class, you need to build a highly similar collection class.
using System.Collections;
public class CarCollection : IEnumerable
{
private ArrayList arCars = new ArrayList();
// Cast for caller.
public Car GetCar(int pos) => (Car) arCars[pos];
// Insert only Car objects. public void AddCar(Car c)
{
arCars.Add(c);
}
public void ClearCars()
{
arCars.Clear();
}
public int Count => arCars.Count;
// Foreach enumeration support.
IEnumerator IEnumerable.GetEnumerator() => arCars.GetEnumerator();
}
However, a custom collection class does nothing to solve the issue of boxing/unboxing penalties.
Even if you were to create a custom collection named IntCollection that you designed to operate only on System.Int32 items, you would have to allocate some type of object to hold the data (e.g., System.Array and ArrayList).
using System.Collections;
public class IntCollection : IEnumerable
{
private ArrayList arInts = new ArrayList();
// Get an int (performs unboxing!).
public int GetInt(int pos) => (int)arInts[pos];
// Insert an int (performs boxing)!
public void AddInt(int i)
{
arInts.Add(i);
}
public void ClearInts()
{
arInts.Clear();
}
public int Count => arInts.Count;
IEnumerator IEnumerable.GetEnumerator() => arInts.GetEnumerator();
}
Regardless of which type you might choose to hold the integers, you cannot escape the boxing dilemma using nongeneric containers.
A First Look at Generic CollectionsT
When you use generic collection classes, you rectify all the previous issues, including boxing/unboxing penalties and a lack of type safety. Also, the need to build a custom (generic) collection class becomes quite rare. Rather than having to build unique classes that can contain people, cars, and integers, you can use a generic collection class and specify the type of type.
Consider the following method (added to the bottom of Program.cs), which uses the generic List
static void UseGenericList()
{
Console.WriteLine(" Fun with Generics \n");
// This List<> can hold only Person objects. List
// This List<> can hold only integers. List
moreInts.Add(2);
int sum = moreInts[0] + moreInts[1];
// Compile-time error! Can't add Person object
// to a list of ints!
// moreInts.Add(new Person());
}
The first List
List
• Generics provide better performance because they do not result in boxing or unboxing penalties when storing value types.
• Generics are type-safe because they can contain only the type of type you specify.
• Generics greatly reduce the need to build custom collection types because you specify the “type of type” when creating the generic container.
The Role of Generic Type Parameters
You can find generic classes, interfaces, structures, and delegates throughout the .NET Core base class libraries, and these might be part of any .NET Core namespace. Also be aware that generics have far more uses than simply defining a collection class. To be sure, you will see many different generics used in the remainder of this book for various reasons.
■ Note only classes, structures, interfaces, and delegates can be written generically; enum types cannot.
When you see a generic item listed in the .NET Core documentation or the Visual Studio Object Browser, you will notice a pair of angled brackets with a letter or other token sandwiched within. Figure 10-1 shows the Visual Studio Object Browser displaying a number of generic items located within the System.
Collections.Generic namespace, including the highlighted List
Figure 10-1. Generic items supporting type parameters
Formally speaking, you call these tokens type parameters; however, in more user-friendly terms, you can simply call them placeholders. You can read the symbol
■ Note the name of a type parameter (placeholder) is irrelevant, and it is up to the developer who created the generic item. However, typically T is used to represent types, TKey or K is used for keys, and TValue or V is used for values.
When you create a generic object, implement a generic interface, or invoke a generic member, it is up to you to supply a value to the type parameter. You’ll see many examples in this chapter and throughout the remainder of the text. However, to set the stage, let’s see the basics of interacting with generic types and members.
Specifying Type Parameters for Generic Classes/Structures
When you create an instance of a generic class or structure, you specify the type parameter when you declare the variable and when you invoke the constructor. As you saw in the preceding code example, UseGenericList() defined two List
// This List<> can hold only Person objects. List
// This List<> can hold only integers. List
You can read the first line in the preceding snippet as “a List<> of T, where T is of type Person.” Or, more simply, you can read it as “a list of person objects.” After you specify the type parameter of a generic item, it cannot be changed (remember, generics are all about type safety). When you specify a type parameter for a generic class or structure, all occurrences of the placeholder(s) are now replaced with your supplied value.
If you were to view the full declaration of the generic List
// A partial listing of the List
public class List
{
...
public void Add(T item);
public void AddRange(IEnumerable
public bool Contains(T item); public void CopyTo(T[] array);
public int FindIndex(System.Predicate
public int RemoveAll(System.Predicate
public bool TrueForAll(System.Predicate
}
When you create a List
namespace System.Collections.Generic; public class List
: IList
{
...
public void Add(Person item);
public void AddRange(IEnumerable
public bool Contains(Person item); public void CopyTo(Person[] array);
public int FindIndex(System.Predicate
public int RemoveAll(System.Predicate
public bool TrueForAll(System.Predicate
}
Of course, when you create a generic List
Specifying Type Parameters for Generic Members
It is fine for a nongeneric class or structure to support generic properties. In these cases, you would also need to specify the placeholder value at the time you invoke the method. For example, System.Array supports several generic methods. Specifically, the nongeneric static Sort() method now has a generic counterpart named Sort
int[] myInts = { 10, 4, 2, 33, 93 };
// Specify the placeholder to the generic
// Sort<>() method.
Array.Sort
foreach (int i in myInts)
{
Console.WriteLine(i);
}
Specifying Type Parameters for Generic Interfaces
It is common to implement generic interfaces when you build classes or structures that need to support various framework behaviors (e.g., cloning, sorting, and enumeration). In Chapter 8, you learned about a number of nongeneric interfaces, such as IComparable, IEnumerable, IEnumerator, and IComparer. Recall that the nongeneric IComparable interface was defined like this:
public interface IComparable
{
int CompareTo(object obj);
}
In Chapter 8, you also implemented this interface on your Car class to enable sorting in a standard array. However, the code required several runtime checks and casting operations because the parameter was a general System.Object.
public class Car : IComparable
{
...
// IComparable implementation.
int IComparable.CompareTo(object obj)
{
if (obj is Car temp)
{
return this.CarID.CompareTo(temp.CarID);
}
throw new ArgumentException("Parameter is not a Car!");
}
}
Now assume you use the generic counterpart of this interface.
public interface IComparable
{
int CompareTo(T obj);
}
In this case, your implementation code will be cleaned up considerably.
public class Car : IComparable
{
...
// IComparable
int IComparable
{
if (this.CarID > obj.CarID)
{
return 1;
}
if (this.CarID < obj.CarID)
{
return -1;
}
return 0;
}
}
Here, you do not need to check whether the incoming parameter is a Car because it can only be a Car! If someone were to pass in an incompatible data type, you would get a compile-time error. Now that you have a better handle on how to interact with generic items, as well as the role of type parameters (aka placeholders), you’re ready to examine the classes and interfaces of the System.Collections.Generic namespace.
The System.Collections.Generic Namespace
When you are building a .NET Core application and need a way to manage in-memory data, the classes of System.Collections.Generic will most likely fit the bill. At the opening of this chapter, I briefly mentioned some of the core nongeneric interfaces implemented by the nongeneric collection classes. Not too surprisingly, the System.Collections.Generic namespace defines generic replacements for many of them.
In fact, you can find a number of the generic interfaces that extend their nongeneric counterparts. This might seem odd; however, by doing so, implementing classes will also support the legacy functionally found in their nongeneric siblings. For example, IEnumerable
Table 10-4. Key Interfaces Supported by Classes of System.Collections.Generic
System.Collections.Generic
Interface Meaning in Life
ICollection
IComparer
IDictionary<TKey, TValue> Allows a generic collection object to represent its contents using key- value pairs.
IEnumerable
IAsyncEnumerable (new in C# 8.0) is covered in Chapter 15.
IEnumerator
IList
ISet
The System.Collections.Generic namespace also defines several classes that implement many of these key interfaces. Table 10-5 describes some commonly used classes of this namespace, the interfaces they implement, and their basic functionality.
Table 10-5. Classes of System.Collections.Generic
Generic Class Supported Key Interfaces Meaning in Life
Dictionary<TKey, TValue> ICollection
LinkedList
List
Queue
SortedDictionary<TKey, TValue> ICollection
SortedSet
Stack
The System.Collections.Generic namespace also defines many auxiliary classes and structures that work in conjunction with a specific container. For example, the LinkedListNode
item from a container using a nonexistent key, and so forth. Be sure to consult the .NET Core documentation for full details of the System.Collections.Generic namespace.
In any case, your next task is to learn how to use some of these generic collection classes. Before you do, however, allow me to illustrate a C# language feature (first introduced in .NET 3.5) that simplifies the way you populate generic (and nongeneric) collection containers with data.
Understanding Collection Initialization Syntax
In Chapter 4, you learned about object initialization syntax, which allows you to set properties on a new variable at the time of construction. Closely related to this is collection initialization syntax. This C# language feature makes it possible to populate many containers (such as ArrayList or List
using System.Collections; using System.Drawing;
■ Note You can apply collection initialization syntax only to classes that support an Add() method, which is formalized by the ICollection
Consider the following examples:
// Init a standard array.
int[] myArrayOfInts = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// Init a generic List<> of ints.
List
// Init an ArrayList with numerical data.
ArrayList myList = new ArrayList { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
If your container is managing a collection of classes or a structure, you can blend object initialization syntax with collection initialization syntax to yield some functional code. You might recall the Point class from Chapter 5, which defined two properties named X and Y. If you wanted to build a generic List
List
{
new Point { X = 2, Y = 2 }, new Point { X = 3, Y = 3 }, new Point { X = 4, Y = 4 }
};
foreach (var pt in myListOfPoints)
{
Console.WriteLine(pt);
}
Again, the benefit of this syntax is that you save yourself numerous keystrokes. While the nested curly brackets can become difficult to read if you don’t mind your formatting, imagine the amount of code that would be required to fill the following List
List
{
new Rectangle {
Height = 90, Width = 90,
Location = new Point { X = 10, Y = 10 }}, new Rectangle {
Height = 50,Width = 50,
Location = new Point { X = 2, Y = 2 }},
};
foreach (var r in myListOfRects)
{
Console.WriteLine(r);
}
Working with the List
Create a new Console Application project named FunWithGenericCollections. Add a new file, named
Person.cs, and add the following code (which is the same code as the previous Person class):
namespace FunWithGenericCollections; public class Person
{
public int Age {get; set;}
public string FirstName {get; set;} public string LastName {get; set;}
public Person(){}
public Person(string firstName, string lastName, int age)
{
Age = age;
FirstName = firstName;
LastName = lastName;
}
public override string ToString()
{
return $"Name: {FirstName} {LastName}, Age: {Age}";
}
}
Clear out the generated code in Program.cs and add the following using statement:
using FunWithGenericCollections;
The first generic class you will examine is List
static void UseGenericList()
{
// Make a List of Person objects, filled with
// collection/object init syntax. List
{
new Person {FirstName= "Homer", LastName="Simpson", Age=47}, new Person {FirstName= "Marge", LastName="Simpson", Age=45}, new Person {FirstName= "Lisa", LastName="Simpson", Age=9}, new Person {FirstName= "Bart", LastName="Simpson", Age=8}
};
// Print out # of items in List. Console.WriteLine("Items in list: {0}", people.Count);
// Enumerate over list. foreach (Person p in people)
{
Console.WriteLine(p);
}
// Insert a new person.
Console.WriteLine("\n->Inserting new person.");
people.Insert(2, new Person { FirstName = "Maggie", LastName = "Simpson", Age = 2 }); Console.WriteLine("Items in list: {0}", people.Count);
// Copy data into a new array.
Person[] arrayOfPeople = people.ToArray(); foreach (Person p in arrayOfPeople)
{
Console.WriteLine("First Names: {0}", p.FirstName);
}
}
Here, you use collection initialization syntax to populate your List
Finally, notice the call to the ToArray() method, which returns an array of Person objects based on the contents of the original List
Fun with Generic Collections Items in list: 4
Name: Homer Simpson, Age: 47 Name: Marge Simpson, Age: 45 Name: Lisa Simpson, Age: 9 Name: Bart Simpson, Age: 8
->Inserting new person.
Items in list: 5
First Names: Homer First Names: Marge First Names: Maggie First Names: Lisa First Names: Bart
The List
Working with the Stack
The Stack
static void UseGenericStack()
{
Stack
stackOfPeople.Push(new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 }); stackOfPeople.Push(new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 }); stackOfPeople.Push(new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 });
// Now look at the top item, pop it, and look again. Console.WriteLine("First person is: {0}", stackOfPeople.Peek()); Console.WriteLine("Popped off {0}", stackOfPeople.Pop()); Console.WriteLine("\nFirst person is: {0}", stackOfPeople.Peek()); Console.WriteLine("Popped off {0}", stackOfPeople.Pop()); Console.WriteLine("\nFirst person item is: {0}", stackOfPeople.Peek()); Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
try
{
Console.WriteLine("\nnFirst person is: {0}", stackOfPeople.Peek()); Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
}
catch (InvalidOperationException ex)
{
Console.WriteLine("\nError! {0}", ex.Message);
}
}
Here, you build a stack that contains three people, added in the order of their first names: Homer, Marge, and Lisa. As you peek into the stack, you will always see the object at the top first; therefore, the first call to Peek() reveals the third Person object. After a series of Pop() and Peek() calls, the stack eventually empties, at which time additional Peek() and Pop() calls raise a system exception. You can see the output for this here:
Fun with Generic Collections First person is: Name: Lisa Simpson, Age: 9 Popped off Name: Lisa Simpson, Age: 9
First person is: Name: Marge Simpson, Age: 45 Popped off Name: Marge Simpson, Age: 45
First person item is: Name: Homer Simpson, Age: 47 Popped off Name: Homer Simpson, Age: 47
Error! Stack empty.
Working with the Queue
Queues are containers that ensure items are accessed in a first-in, first-out manner. Sadly, we humans are subject to queues all day long: lines at the bank, lines at the movie theater, and lines at the morning coffeehouse. When you need to model a scenario in which items are handled on a first-come, first-served
basis, you will find the Queue
Table 10-6. Members of the Queue
Select Member of Queue
Dequeue() Removes and returns the object at the beginning of the Queue
Enqueue() Adds an object to the end of the Queue
Peek() Returns the object at the beginning of the Queue
Now let’s put these methods to work. You can begin by leveraging your Person class again and building a Queue
static void UseGenericQueue()
{
// Make a Q with three people. Queue
peopleQ.Enqueue(new Person {FirstName= "Homer", LastName="Simpson", Age=47});
peopleQ.Enqueue(new Person {FirstName= "Marge", LastName="Simpson", Age=45}); peopleQ.Enqueue(new Person {FirstName= "Lisa", LastName="Simpson", Age=9});
// Peek at first person in Q.
Console.WriteLine("{0} is first in line!", peopleQ.Peek().FirstName);
// Remove each person from Q. GetCoffee(peopleQ.Dequeue()); GetCoffee(peopleQ.Dequeue()); GetCoffee(peopleQ.Dequeue());
// Try to de-Q again? try
{
GetCoffee(peopleQ.Dequeue());
}
catch(InvalidOperationException e)
{
Console.WriteLine("Error! {0}", e.Message);
}
//Local helper function
static void GetCoffee(Person p)
{
Console.WriteLine("{0} got coffee!", p.FirstName);
}
}
Here, you insert three items into the Queue
Fun with Generic Collections Homer is first in line!
Homer got coffee!
Marge got coffee!
Lisa got coffee!
Error! Queue empty.
Working with the PriorityQueue<TElement, TPriority> Class (New 10)
Introduced in .NET 6/C# 10, the PriorityQueue works just like the Queue
static void UsePriorityQueue()
{
Console.WriteLine(" Fun with Generic Priority Queues \n");
PriorityQueue<Person, int> peopleQ = new();
peopleQ.Enqueue(new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 }, 1); peopleQ.Enqueue(new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 }, 3); peopleQ.Enqueue(new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 }, 3); peopleQ.Enqueue(new Person { FirstName = "Bart", LastName = "Simpson", Age = 12 }, 2);
while (peopleQ.Count > 0)
{
Console.WriteLine(peopleQ.Dequeue().FirstName); //Displays Lisa Console.WriteLine(peopleQ.Dequeue().FirstName); //Displays Bart Console.WriteLine(peopleQ.Dequeue().FirstName); //Displays either Marge or Homer Console.WriteLine(peopleQ.Dequeue().FirstName); //Displays the other priority 3 item
}
}
If more than one item is set to the current lowest priority, the order of dequeuing is not guaranteed. As shown in code sample, the third call to Dequeue() will return either Homer or Marge, as they are both set to a priority of three. The fourth call will then return the other person. If exact order matters, you must ensure values for each priority are unique.
Working with the SortedSet
The SortedSet
want it to sort the objects, by passing in as a constructor argument an object that implements the generic
IComparer
Begin by creating a new class named SortPeopleByAge, which implements IComparer
namespace FunWithGenericCollections; class SortPeopleByAge : IComparer
{
public int Compare(Person firstPerson, Person secondPerson)
{
if (firstPerson?.Age > secondPerson?.Age)
{
return 1;
}
if (firstPerson?.Age < secondPerson?.Age)
{
return -1;
}
return 0;
}
}
Now add the following new method that demonstrates using SortedSet
static void UseSortedSet()
{
// Make some people with different ages.
SortedSet
{
new Person {FirstName= "Homer", LastName="Simpson", Age=47}, new Person {FirstName= "Marge", LastName="Simpson", Age=45}, new Person {FirstName= "Lisa", LastName="Simpson", Age=9}, new Person {FirstName= "Bart", LastName="Simpson", Age=8}
};
// Note the items are sorted by age!
foreach (Person p in setOfPeople)
{
Console.WriteLine(p);
}
Console.WriteLine();
// Add a few new people, with various ages.
setOfPeople.Add(new Person { FirstName = "Saku", LastName = "Jones", Age = 1 }); setOfPeople.Add(new Person { FirstName = "Mikko", LastName = "Jones", Age = 32 });
// Still sorted by age!
foreach (Person p in setOfPeople)
{
Console.WriteLine(p);
}
}
When you run your application, the listing of objects is now always ordered based on the value of the
Age property, regardless of the order you inserted or removed objects.
Fun with Generic Collections Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9 Name: Marge Simpson, Age: 45 Name: Homer Simpson, Age: 47
Name: Saku Jones, Age: 1 Name: Bart Simpson, Age: 8 Name: Lisa Simpson, Age: 9 Name: Mikko Jones, Age: 32 Name: Marge Simpson, Age: 45 Name: Homer Simpson, Age: 47
Working with the Dictionary<TKey, TValue> Class
Another handy generic collection is the Dictionary<TKey,TValue> type, which allows you to hold any number of objects that may be referred to via a unique key. Thus, rather than obtaining an item from a List
Like other collection objects, you can populate a Dictionary<TKey,TValue> by calling the generic Add() method manually. However, you can also fill a Dictionary<TKey,TValue> using collection initialization syntax. Do be aware that when you are populating this collection object, key names must be unique. If you mistakenly specify the same key multiple times, you will receive a runtime exception.
Consider the following method that fills a Dictionary<K,V> with various objects. Notice when you create the Dictionary<TKey,TValue> object, you specify the key type (TKey) and underlying object type (TValue) as constructor arguments. In this example, you are using a string data type as the key and
a Person type as the value. Also note that you can combine object initialization syntax with collection initialization syntax.
private static void UseDictionary()
{
// Populate using Add() method
Dictionary<string, Person> peopleA = new Dictionary<string, Person>(); peopleA.Add("Homer", new Person { FirstName = "Homer", LastName = "Simpson", Age
= 47 });
peopleA.Add("Marge", new Person { FirstName = "Marge", LastName = "Simpson", Age
= 45 });
peopleA.Add("Lisa", new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 });
// Get Homer.
Person homer = peopleA["Homer"]; Console.WriteLine(homer);
// Populate with initialization syntax.
Dictionary<string, Person> peopleB = new Dictionary<string, Person>()
{
{ "Homer", new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 } },
{ "Marge", new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 } },
{ "Lisa", new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 } }
};
// Get Lisa.
Person lisa = peopleB["Lisa"]; Console.WriteLine(lisa);
}
It is also possible to populate a Dictionary<TKey,TValue> using a related initialization syntax that is specific to this type of container (not surprisingly termed dictionary initialization). Similar to the syntax used to populate the personB object in the previous code example, you still define an initialization scope for the collection object; however, you can use the indexer to specify the key and assign this to a new object as so:
// Populate with dictionary initialization syntax.
Dictionary<string, Person> peopleC = new Dictionary<string, Person>()
{
["Homer"] = new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 }, ["Marge"] = new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 }, ["Lisa"] = new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 }
};
The System.Collections.ObjectModel Namespace
Now that you understand how to work with the major generic classes, we will briefly examine an additional collection-centric namespace, System.Collections.ObjectModel. This is a relatively small namespace, which contains a handful of classes. Table 10-7 documents the two classes that you should most certainly be aware of.
Table 10-7. Useful Members of System.Collections.ObjectModel
System.Collections.
ObjectModel Type Meaning in Life
ObservableCollection
ReadOnlyObservable Collection
The ObservableCollection
Working with ObservableCollection
Create a new Console Application project named FunWithObservableCollections and import the System.Collections.ObjectModel namespace into your initial C# code file. In many ways, working with ObservableCollection
Like any event, CollectionChanged is defined in terms of a delegate, which in this case is NotifyCollectionChangedEventHandler. This delegate can call any method that takes an object as the first parameter and takes a NotifyCollectionChangedEventArgs as the second. Consider the following code, which populates an observable collection containing Person objects and wires up the CollectionChanged event:
using System.Collections.ObjectModel; using System.Collections.Specialized; using FunWithObservableCollections;
// Make a collection to observe
//and add a few Person objects.
ObservableCollection
{
new Person{ FirstName = "Peter", LastName = "Murphy", Age = 52 }, new Person{ FirstName = "Kevin", LastName = "Key", Age = 48 },
};
// Wire up the CollectionChanged event. people.CollectionChanged += people_CollectionChanged;
static void people_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
throw new NotImplementedException();
}
The incoming NotifyCollectionChangedEventArgs parameter defines two important properties, OldItems and NewItems, which will give you a list of items that were currently in the collection before the event fired and the new items that were involved in the change. However, you will want to examine these lists only under the correct circumstances. Recall that the CollectionChanged event can fire when items are added, removed, relocated, or reset. To discover which of these actions triggered the event, you can use the Action property of NotifyCollectionChangedEventArgs. The Action property can be tested against any of the following members of the NotifyCollectionChangedAction enumeration:
public enum NotifyCollectionChangedAction
{
Add = 0,
Remove = 1,
Replace = 2,
Move = 3,
Reset = 4,
}
Here is an implementation of the CollectionChanged event handler that will traverse the old and new sets when an item has been inserted into or removed from the collection at hand (notice the using for System.Collections.Specialized):
using System.Collections.Specialized;
...
static void people_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
// What was the action that caused the event? Console.WriteLine("Action for this event: {0}", e.Action);
// They removed something.
if (e.Action == NotifyCollectionChangedAction.Remove)
{
Console.WriteLine("Here are the OLD items:"); foreach (Person p in e.OldItems)
{
Console.WriteLine(p.ToString());
}
Console.WriteLine();
}
// They added something.
if (e.Action == NotifyCollectionChangedAction.Add)
{
// Now show the NEW items that were inserted. Console.WriteLine("Here are the NEW items:"); foreach (Person p in e.NewItems)
{
Console.WriteLine(p.ToString());
}
}
}
Now, update your calling code to add and remove an item.
// Now add a new item.
people.Add(new Person("Fred", "Smith", 32));
// Remove an item. people.RemoveAt(0);
When you run the program, you will see output similar to the following:
Action for this event: Add Here are the NEW items:
Name: Fred Smith, Age: 32
Action for this event: Remove Here are the OLD items:
Name: Peter Murphy, Age: 52
That wraps up the examination of the various collection-centric namespaces. To conclude the chapter, you will now examine how you can build your own custom generic methods and custom generic types.
Creating Custom Generic Methods
While most developers typically use the existing generic types within the base class libraries, it is also possible to build your own generic members and custom generic types. Let’s look at how to incorporate custom generics into your own projects. The first step is to build a generic swap method. Begin by creating a new console application named CustomGenericMethods.
When you build custom generic methods, you achieve a supercharged version of traditional method overloading. In Chapter 2, you learned that overloading is the act of defining multiple versions of a single method, which differ by the number of, or type of, parameters.
While overloading is a useful feature in an object-oriented language, one problem is that you can easily end up with a ton of methods that essentially do the same thing. For example, assume you need to build some methods that can switch two pieces of data using a simple swap routine. You might begin by authoring a new static class with a method that can operate on integers, like this:
namespace CustomGenericMethods; static class SwapFunctions
{
// Swap two integers.
static void Swap(ref int a, ref int b)
{
int temp = a; a = b;
b = temp;
}
}
So far, so good. But now assume you also need to swap two Person objects; this would require authoring a new version of Swap().
// Swap two Person objects.
static void Swap(ref Person a, ref Person b)
{
Person temp = a; a = b;
b = temp;
}
No doubt, you can see where this is going. If you also needed to swap floating-point numbers, bitmaps, cars, buttons, etc., you would have to build even more methods, which would become a maintenance nightmare. You could build a single (nongeneric) method that operated on object parameters, but then you face all the issues you examined earlier in this chapter, including boxing, unboxing, a lack of type safety, explicit casting, and so on.
Whenever you have a group of overloaded methods that differ only by incoming arguments, this is your clue that generics could make your life easier. Consider the following generic Swap
// This method will swap any two items.
// as specified by the type parameter
{
Console.WriteLine("You sent the Swap() method a {0}", typeof(T)); T temp = a;
a = b;
b = temp;
}
Notice how a generic method is defined by specifying the type parameters after the method name but before the parameter list. Here, you state that the Swap
Console.WriteLine("***** Fun with Custom Generic Methods *****\n");
// Swap 2 ints.
int a = 10, b = 90;
Console.WriteLine("Before swap: {0}, {1}", a, b); SwapFunctions.Swap
// Swap 2 strings.
string s1 = "Hello", s2 = "There"; Console.WriteLine("Before swap: {0} {1}!", s1, s2); SwapFunctions.Swap
Console.ReadLine();
The output looks like this:
***** Fun with Custom Generic Methods ***** Before swap: 10, 90
You sent the Swap() method a System.Int32 After swap: 90, 10
Before swap: Hello There!
You sent the Swap() method a System.String After swap: There Hello!
The major benefit of this approach is that you have only one version of Swap
Inference of Type Parameters
When you invoke generic methods such as Swap
// Compiler will infer System.Boolean. bool b1 = true, b2 = false;
Console.WriteLine("Before swap: {0}, {1}", b1, b2); SwapFunctions.Swap(ref b1, ref b2); Console.WriteLine("After swap: {0}, {1}", b1, b2);
Even though the compiler can discover the correct type parameter based on the data type used to declare b1 and b2, you should get in the habit of always specifying the type parameter explicitly.
SwapFunctions.Swap
This makes it clear to your fellow programmers that this method is indeed generic. Moreover, inference of type parameters works only if the generic method has at least one parameter. For example, assume you have the following generic method in your Program.cs file:
static void DisplayBaseClass
{
// BaseType is a method used in reflection,
// which will be examined in Chapter 17
Console.WriteLine("Base class of {0} is: {1}.", typeof(T), typeof(T).BaseType);
}
In this case, you must supply the type parameter upon invocation.
...
// Must supply type parameter if
// the method does not take params. DisplayBaseClass
// Compiler error! No params? Must supply placeholder!
// DisplayBaseClass();
Console.ReadLine();
Of course, generic methods do not need to be static as they are in these examples. All rules and options for nongeneric methods also apply.
Creating Custom Generic Structures and Classes
Now that you understand how to define and invoke generic methods, it’s time to turn your attention to the construction of a generic structure (the process of building a generic class is identical) within a new Console Application project named GenericPoint. Assume you have built a generic Point structure that supports a single type parameter that represents the underlying storage for the (x, y) coordinates. The caller can then create Point
// Point using ints.
Point
// Point using double.
Point
// Point using strings.
Point
Creating a point using strings might seem a bit odd at first, but consider the case of imaginary numbers. Then it might make sense to use strings for the values of X and Y of a point. Regardless, it demonstrates the power of generics. Here is the complete definition of Point
namespace GenericPoint;
// A generic Point structure. public struct Point
{
// Generic state data. private T _xPos; private T _yPos;
// Generic constructor. public Point(T xVal, T yVal)
{
_xPos = xVal;
_yPos = yVal;
}
// Generic properties. public T X
{
get => _xPos;
set => _xPos = value;
}
public T Y
{
get => _yPos;
set => _yPos = value;
}
public override string ToString() => $"[{_xPos}, {_yPos}]";
}
As you can see, Point
Default Value Expressions with Generics
With the introduction of generics, the C# default keyword has been given a dual identity. In addition to its use within a switch construct, it can be used to set a type parameter to its default value. This is helpful
because a generic type does not know the actual placeholders up front, which means it cannot safely assume what the default value will be. The defaults for a type parameter are as follows:
• Numeric values have a default value of 0.
• Reference types have a default value of null.
• Fields of a structure are set to 0 (for value types) or null (for reference types).
To reset an instance of Point
// Reset fields to the default value of the type parameter.
// The "default" keyword is overloaded in C#.
// When used with generics, it represents the default
// value of a type parameter. public void ResetPoint()
{
_xPos = default(T);
_yPos = default(T);
}
Now that you have the ResetPoint() method in place, you can fully exercise the methods of
Point
Console.WriteLine("***** Fun with Generic Structures *****\n");
// Point using ints.
Point
// Point using double.
Point
// Point using strings.
Point
Here is the output:
***** Fun with Generic Structures ***** p.ToString()=[10, 10]
p.ToString()=[0, 0]
p2.ToString()=[5.4, 3.3]
p2.ToString()=[0, 0]
p3.ToString()=[i, 3i] p3.ToString()=[, ]
Default Literal Expressions (New 7.1)
In addition to setting the default value of a property, C# 7.1 introduced default literal expressions. This eliminates the need for specifying the type of the variable in the default statement. Update the ResetPoint() method to the following:
public void ResetPoint()
{
_xPos = default;
_yPos = default;
}
The default expression isn’t limited to simple variables but can also be applied to complex types. For example, to create and initialize the Point structure, you can write the following:
Point
Point
Pattern Matching with Generics (New 7.1)
Another update in C# 7.1 is the ability to pattern match on generics. Take the following method, which checks the Point instance for the data type that it is based on (arguably incomplete, but enough to show the concept):
static void PatternMatching
{
switch (p)
{
case Point
case Point
Console.WriteLine("Point is based on ints"); return;
}
}
To exercise the pattern matching code, update the top-level statements to the following:
Point
Constraining Type Parameters
As this chapter illustrates, any generic item has at least one type parameter that you need to specify at the time you interact with the generic type or member. This alone allows you to build some type-safe code; however, you can also use the where keyword to get extremely specific about what a given type parameter must look like.
Using this keyword, you can add a set of constraints to a given type parameter, which the C# compiler will check at compile time. Specifically, you can constrain a type parameter as described in Table 10-8.
Table 10-8. Possible Constraints for Generic Type Parameters
Generic Constraint Meaning in Life
where T : struct The type parameter
where T : class The type parameter
where T : new() The type parameter
where T : NameOfBaseClass The type parameter
NameOfBaseClass.
where T : NameOfInterface The type parameter
NameOfInterface. You can separate multiple interfaces as a comma-delimited list.
Unless you need to build some extremely type-safe custom collections, you might never need to use the where keyword in your C# projects. Regardless, the following handful of (partial) code examples illustrate how to work with the where keyword.
Examples of Using the where Keyword
Begin by assuming that you have created a custom generic class, and you want to ensure that the type parameter has a default constructor. This could be useful when the custom generic class needs to create instances of the T because the default constructor is the only constructor that is potentially common to all types. Also, constraining T in this way lets you get compile-time checking; if T is a reference type, the programmer remembered to redefine the default in the class definition (you might recall that the default constructor is removed in classes when you define your own).
// MyGenericClass derives from object, while
// contained items must have a default ctor. public class MyGenericClass
{
...
}
Notice that the where clause specifies which type parameter is being constrained, followed by a colon operator. After the colon operator, you list each possible constraint (in this case, a default constructor). Here is another example:
// MyGenericClass derives from object, while
// contained items must be a class implementing IDrawable
// and must support a default ctor.
public class MyGenericClass
{
...
}
In this case, T has three requirements. It must be a reference type (not a structure), as marked with the class token. Second, T must implement the IDrawable interface. Third, it must also have a default
constructor. Multiple constraints are listed in a comma-delimited list; however, you should be aware that the
new() constraint must always be listed last! Thus, the following code will not compile:
// Error! new() constraint must be listed last!
public class MyGenericClass
{
...
}
If you ever create a custom generic collection class that specifies multiple type parameters, you can specify a unique set of constraints for each, using separate where clauses.
//
// while
// generic IComparable interface.
public class MyGenericClass
{
...
}
You will rarely encounter cases where you need to build a complete custom generic collection class; however, you can use the where keyword on generic methods as well. For example, if you want to specify that your generic Swap
// This method will swap any structure, but not classes. static void Swap
{
...
}
Note that if you were to constrain the Swap() method in this manner, you would no longer be able to swap string objects (as is shown in the sample code) because string is a reference type.
The Lack of Operator Constraints
I want to make one more comment about generic methods and constraints as this chapter ends. It might come as a surprise to you to find out that when creating generic methods, you will get a compiler error if you apply any C# operators (+, -, *, ==, etc.) on the type parameters. For example, imagine the usefulness of a class that can add, subtract, multiply, and divide generic types.
// Compiler error! Cannot apply
// operators to type parameters! public class BasicMath
{
public T Add(T arg1, T arg2)
{ return arg1 + arg2; }
public T Subtract(T arg1, T arg2)
{ return arg1 - arg2; }
public T Multiply(T arg1, T arg2)
{ return arg1 * arg2; }
public T Divide(T arg1, T arg2)
{ return arg1 / arg2; }
}
Unfortunately, the preceding BasicMath class will not compile. While this might seem like a major restriction, you need to remember that generics are generic. Of course, the numerical data can work with the binary operators of C#. However, for the sake of argument, if
// Illustrative code only!
public class BasicMath
{
public T Add(T arg1, T arg2)
{ return arg1 + arg2; }
public T Subtract(T arg1, T arg2)
{ return arg1 - arg2; }
public T Multiply(T arg1, T arg2)
{ return arg1 * arg2; }
public T Divide(T arg1, T arg2)
{ return arg1 / arg2; }
}
Alas, operator constraints are not supported under the current version of C#. However, it is possible (albeit it requires a bit more work) to achieve the desired effect by defining an interface that supports these operators (C# interfaces can define operators!) and then specifying an interface constraint of the generic class. In any case, this wraps up this book’s initial look at building custom generic types. In Chapter 12, I will pick up the topic of generics once again while examining the delegate type.
Summary
This chapter began by examining the nongeneric collection types of System.Collections and System. Collections.Specialized, including the various issues associated with many nongeneric containers, such as a lack of type safety and the runtime overhead of boxing and unboxing operations. As mentioned, for these very reasons, modern-day .NET programs will typically make use of the generic collection classes found in System.Collections.Generic and System.Collections.ObjectModel.
As you have seen, a generic item allows you to specify placeholders (type parameters) at the time of object creation (or invocation, in the case of generic methods). While you will most often simply use the generic types provided in the .NET base class libraries, you will also be able to create your own generic types (and generic methods). When you do so, you have the option of specifying any number of constraints (using the where keyword) to increase the level of type safety and ensure that you perform operations on types of a known quantity that are guaranteed to exhibit certain basic capabilities.
As a final note, remember that generics are found in numerous locations within the .NET base class libraries. Here, you focused specifically on generic collections. However, as you work through the remainder of this book (and when you dive into the platform on your own terms), you will certainly find generic classes, structures, and delegates located in a given namespace. As well, be on the lookout for generic members of a nongeneric class!