Chapter 8
IN THIS CHAPTER
Making your code generic — and truly powerful
Writing your own generic class
Writing generic methods
Using generic interfaces and delegates
The problem with collections is that you need to know exactly what you’re sending to them. Can you imagine a recipe that accepts only the exact listed ingredients and no others? No substitutions — nothing even named differently? That's how most collections treat you, but not generics.
As with prescriptions at your local pharmacy, you can save big by opting for the generic version. Generics are fill-in-the-blanks classes, methods, interfaces, and delegates. For example, the List<T>
class defines a generic array-like list that's quite comparable to the older, nongeneric ArrayList
— but better! When you pull List<T>
off the shelf to instantiate your own list of, say, int
s, you replace T
with int
:
List<int> myList = new List<int>(); // A list limited to ints
The versatile part is that you can instantiate List<T>
for any single data type (string
, Student
, BankAccount
, CorduroyPants
— whatever), and it's still type-safe like an array, without nongeneric costs. It’s the superarray. (This chapter explains type-safety and the costs of using nongeneric collections before you discover how to create a generic class because knowing what these terms mean is essential.)
Generics come in two flavors in C#: the built-in generics, such as List<T>
, and a variety of roll-your-own items. After a quick tour of generic concepts, this chapter covers roll-your-own generic classes, generic methods, and generic interfaces and delegates.
What's so hot about generics? They excel for two reasons: safety and performance.
The first consequence of nongenerics lacking type-safety is that you need a cast, as shown in the following code, to get the original object out of the ArrayList
because it’s hidden inside an Object
:
ArrayList aList = new ArrayList();
// Add five or six items, then …
string myString = (string)aList[4]; // Cast to string.
ArrayList aList = new ArrayList();
aList.Add("a string"); // string -- OK
aList.Add(3); // int -- OK
aList.Add(aStudent); // Student -- OK
However, if you put a mixture of incompatible types into an ArrayList
(or another nongeneric collection), how do you know what type is in, say, aList[3]
? If it's aStudent
and you try to cast it to string
, you get a runtime error. It's just like Harry Potter reaching into a box of Bertie Botts’s Every Flavor Beans: He doesn’t know whether he’ll grab raspberry beans or earwax.
// See if the object is the right type, then cast it …
if (aList[i] is Student) // Is the object there a Student?
{
Student theStudent = (Student)aList[i]; // Yes, so it's safe to cast.
}
// Or do the conversion and see if it went well…
Student aStudent = aList[i] as Student; // Extract a Student, if present;
if (aStudent != null) // if not, "as" returns null.
{
// OK to use aStudent; "as" operator worked.
}
You can avoid all this extra work by using generics. Generic collections work like arrays: You specify the one and only type they can hold when you declare them.
Polymorphism allows the type Object
to hold any other type, as with the egg carton analogy in the previous section. But you can incur a penalty by putting in value-type objects — numeric, char
, and bool
types and structs
— and taking them out. That's because value-type objects that you add have to be boxed. (See Book 2 for more on polymorphism.)
Boxing isn’t worrisome unless your collection is big (although the amount of boxing going on can startle you and be more costly than you imagined). If you’re stuffing a thousand, or a million, int
s into a nongeneric collection, it takes about 20 times as long, plus extra space on the memory heap, where reference-type objects are stored. Boxing can also lead to subtle errors that will have you tearing your hair out. Generic collections eliminate boxing and unboxing.
Besides the built-in generic collection classes, C# lets you write your own generic classes, regardless of whether they’re collections. The point is that you can create generic versions of classes that you design.
Picture a class definition full of <T>
notations. When you instantiate such a class, you specify a type to replace its generic placeholders, just as you do with the generic collections. Note how similar these declarations are:
LinkedList<int> aList = new LinkedList<int>(); // Built-in LinkedList class
MyClass<int> aClass = new MyClass<int>(); // Custom class
Both are instantiations of classes — one built-in and one programmer-defined. Not every class makes sense as a generic; in the section “Writing generic code the easy way,” later in this chapter, you see an example of one that does.
To show you how to write your own generic class, the PriorityQueue
example develops a special kind of queue collection class, a priority queue.
Here's the scene for an example: a busy shipping warehouse similar to UPS or FedEx. Packages stream in the front door at OOPs, Inc., and are shipped out the back as soon as they can be processed. Some packages need to be delivered by way of superfast, next-day teleportation; others can travel a tiny bit slower, by second-day cargo pigeon; and most can take the snail route: ground delivery in your cousin Fred’s ’82 Volvo.
But the packages don’t arrive at the warehouse in any particular order, so as they come in, you need to expedite some of them as next-day or second-day. Because some packages are more equal than others, they are prioritized, and the folks in the warehouse give the high-priority packages special treatment.
Except for the priority aspect, this situation is tailor-made for a queue data structure. A queue is perfect for anything that involves turn-taking. You’ve stood (or driven) in thousands of queues in your life, waiting for your turn to buy Twinkies or pay too much for prescription medicines. You know the drill.
The shipping warehouse scenario is similar: New packages arrive and go to the back of the line — normally. But because some have higher priorities, they’re privileged characters, like those Premium Class folks at the airport ticket counter. They get to jump ahead, either to the front of the line or not far from the front.
The shipping queue at OOPs deals with high-, medium-, and low-priority packages coming in. Here are the queuing rules:
C# comes with built-in queues, even generic ones. But older versions don’t come with a priority queue, so you have to build your own. How? A common approach is to embed several actual queues within a wrapper class, sort of like this:
class Wrapper // Or PriorityQueue
{
Queue queueHigh = new Queue ();
Queue queueMedium = new Queue ();
Queue queueLow = new Queue ();
// Methods to manipulate the underlying queues…
Wrappers are classes (or methods) that encapsulate complexity. A wrapper may have an interface quite different from the interfaces of what’s inside it — that’s an adapter.
The wrapper encapsulates three actual queues here (they could be generic), and the wrapper must manage what goes into which underlying queue and how. The standard interface to the Queue
class, as implemented in C#, includes these two key methods:
Enqueue()
(pronounced “N-Q”) inserts items into a queue at the back.Dequeue()
(pronounced “D-Q”) removes items from the queue at the front.Add a random number (0 - 20) of random packages to queue:
Creating 15 packages:
Generating and adding random package 0 with priority High
Generating and adding random package 1 with priority Medium
Generating and adding random package 2 with priority Low
Generating and adding random package 3 with priority High
Generating and adding random package 4 with priority Low
Generating and adding random package 5 with priority Low
Generating and adding random package 6 with priority High
Generating and adding random package 7 with priority Medium
Generating and adding random package 8 with priority Low
Generating and adding random package 9 with priority Low
Generating and adding random package 10 with priority High
Generating and adding random package 11 with priority Low
Generating and adding random package 12 with priority Low
Generating and adding random package 13 with priority Medium
Generating and adding random package 14 with priority Medium
See what we got:
Packages received: 15
Remove a random number of packages (0-20):
Removing up to 8 packages
Shipped package with priority High
Shipped package with priority High
Shipped package with priority High
Shipped package with priority High
Shipped package with priority Medium
Shipped package with priority Medium
Shipped package with priority Medium
Shipped package with priority Medium
This example relies on a simplified example package. Class
focuses on the priority part, although a real Package
object would include other members. Here's the code for Package
class Package
:
class Package : IPrioritizable
{
private Priority _priority;
//Constructor
public Package(Priority priority) => _priority = priority;
//Priority -- Return package priority -- read-only.
public Priority Priority
{
get => _priority;
}
// Plus ToAddress, FromAddress, Insurance, etc.
}
All that Package
needs for the example are
Two aspects of class Package
require some explanation: the Priority
type and the IPrioritizable
interface that Package
implements.
Priorities are measured with an enumerated type, or enum
, named Priority
. The Priority
enum
looks like this:
enum Priority
{
Low, Medium, High
}
Any object going into the PriorityQueue
must “know” its own priority. (A general object-oriented principle states that objects should be responsible for themselves.)
interface IPrioritizable
{
Priority Priority { get; } // Example of a property in an interface
}
The notation { get; } is how to write a property in an interface declaration (as described in Book 2, Chapter 7. Class Package
implements the interface by providing a fleshed-out implementation for the Priority
property:
public Priority Priority
{
get => _priority;
}
You encounter the other side of this enforceable requirement in the declaration of class PriorityQueue
, in the later section “Saving PriorityQueue for last.”
Before you spelunk the PriorityQueue
class, it's useful to get an overview of how it works in practice at OOPs, Inc. Here’s the Main()
method for the PriorityQueue
example:
static void Main(string[] args)
{
Console.WriteLine("Create a priority queue:");
PriorityQueue<Package> pq = new PriorityQueue<Package>();
Console.WriteLine(
"Add a random number (0 - 20) of random packages to queue:");
Package pack;
PackageFactory fact = new PackageFactory();
// You want a random number less than 20.
Random rand = new Random(2);
int numToCreate = rand.Next(20); // Random int from 0 - 20
Console.WriteLine(" Creating {0} packages: ", numToCreate);
for (int i = 0; i < numToCreate; i++)
{
Console.Write(" Generating and adding random package {0}", i);
pack = fact.CreatePackage();
Console.WriteLine(" with priority {0}", pack.Priority);
pq.Enqueue(pack);
}
Console.WriteLine("See what we got:");
int total = pq.Count;
Console.WriteLine("Packages received: {0}", total);
Console.WriteLine("Remove a random number of packages (0-20): ");
int numToRemove = rand.Next(20);
Console.WriteLine(" Removing up to {0} packages", numToRemove);
for (int i = 0; i < numToRemove; i++)
{
pack = pq.Dequeue();
if (pack != null)
{
Console.WriteLine(" Shipped package with priority {0}",
pack.Priority);
}
}
Console.Read();
}
Here's what happens in Main()
:
PriorityQueue
object for type Package
.Create a PackageFactory
object whose job is to create new packages with randomly selected priorities, on demand.
A factory is a class or method that creates objects for you. You tour PackageFactory
in the section “Using a (nongeneric) Simple Factory class,” later in this chapter.
Random
to generate a random number and then call PackageFactory
to create that number of new Package
objects with random priorities.PriorityQueue
by using pq.Enqueue(pack)
.PriorityQueue
by using pq.Dequeue()
.Now you have to figure out how to write a generic class, with all those <T>
s. Looks confusing, doesn't it? Well, it’s not so hard, as this section demonstrates.
class PriorityQueue<T> where T : IPrioritizable
{
//Queues -- the three underlying queues: all generic!
private Queue<T> _queueHigh = new Queue<T>();
private Queue<T> _queueMedium = new Queue<T>();
private Queue<T> _queueLow = new Queue<T>();
//Enqueue -- Prioritize T and add an item of type T to correct queue.
// The item must know its own priority.
public void Enqueue(T item)
{
switch (item.Priority) // Require IPrioritizable for this property.
{
case Priority.High:
_queueHigh.Enqueue(item);
break;
case Priority.Medium:
_queueMedium.Enqueue(item);
break;
case Priority.Low:
_queueLow.Enqueue(item);
break;
default:
throw new ArgumentOutOfRangeException(
item.Priority.ToString(),
"bad priority in PriorityQueue.Enqueue");
}
}
// And so on …
Testing the logic of the class is easier when you write the class nongenerically first. When all the logic is straight, you can use find-and-replace to replace the name Package
with T
.
Why would a priority queue be last? It may seem a little backward, but you've seen the code that relies on the priority queue to perform tasks. Now it’s time to examine the PriorityQueue
class. This section shows the code and then walks you through it so that you see how to deal with a couple of small issues. Take it a piece at a time.
PriorityQueue
is a wrapper class that hides three ordinary Queue<T>
objects, one for each priority level. Here's the first part of PriorityQueue
, showing the three underlying queues (now generic):
private Queue<T> _queueHigh = new Queue<T>();
private Queue<T> _queueMedium = new Queue<T>();
private Queue<T> _queueLow = new Queue<T>();
The lines declare three private data members of type Queue<T>
and initialize them by creating the Queue<T>
objects. The T
(type) used for the three queues must implement the IPrioritizable
interface. Otherwise, the compiler raises an error during compilation. Consequently, you can't try to create a Queue<T>
of type int
(as an example) because int
lacks the IPrioritizable
interface. The “Understanding constraints” section, later in the chapter, explains the use of this feature in more detail.
Enqueue()
adds an item of type T
to the PriorityQueue
. This method's job is to look at the item’s priority and put it into the correct underlying queue. In the first line, it gets the item’s Priority
property and switches based on that value. To add the item to the high-priority queue, for example, Enqueue()
turns around and enqueues the item in the underlying _queueHigh
. Here's PriorityQueue
’s Enqueue()
method:
public void Enqueue(T item)
{
switch (item.Priority) // Require IPrioritizable for this property.
{
case Priority.High:
_queueHigh.Enqueue(item);
break;
case Priority.Medium:
_queueMedium.Enqueue(item);
break;
case Priority.Low:
_queueLow.Enqueue(item);
break;
default:
throw new ArgumentOutOfRangeException(
item.Priority.ToString(),
"bad priority in PriorityQueue.Enqueue");
}
}
Dequeue()
's job is a bit trickier than Enqueue()
’s: It must locate the highest-priority underlying queue that has contents and then retrieve the front item from that sub-queue. Dequeue()
delegates the first part of the task, finding the highest-priority queue that isn't empty, to a private TopQueue()
method (described in the next section). Then Dequeue()
calls the underlying queue's Dequeue()
method to retrieve the frontmost object, which it returns. Here’s how Dequeue()
works:
public T Dequeue()
{
// Find highest-priority queue with items.
Queue<T> queueTop = TopQueue();
// If a non-empty queue is found.
if (queueTop != null && queueTop.Count > 0)
{
return queueTop.Dequeue(); // Return its front item.
}
// If all queues empty, return null (you could throw exception).
return default(T); // What's this? See discussion.
}
A difficulty arises only if none of the underlying queues have any packages — in other words, the whole PriorityQueue
is empty. What do you return in that case? It’s that odd duck, default(T)
, at the end. The later “Determining the null value for type T: default(T)” section deals with default(T)
.
Dequeue()
relies on the private method TopQueue()
to find the highest-priority, non-empty underlying queue. TopQueue()
just starts with _queueHigh
and asks for its Count
property. If it's greater than zero, the queue contains items, so TopQueue()
returns a reference to the whole underlying queue that it found. (The TopQueue()
return type is Queue<T>
.) On the other hand, if _queueHigh
is empty, TopQueue()
tries _queueMedium
and then _queueLow
. What happens if all subqueues are empty? TopQueue()
returns null
. TopQueue()
works like this:
private Queue<T> TopQueue()
{
if (_queueHigh.Count > 0) // Anything in high-priority queue?
return _queueHigh;
if (_queueMedium.Count > 0) // Anything in medium-priority queue?
return _queueMedium;
if (_queueLow.Count > 0) // Anything in low-priority queue?
return _queueLow;
return null; // All empty, so return null.
}
PriorityQueue
is useful because it knows how many items it contains. (An object should be responsible for itself.) Look at PriorityQueue's
Count
property. You might also find it useful to include methods that return the number of items in each of the underlying queues. Be careful: Doing so may reveal too much about how the priority queue is implemented. Keep your implementation private. Here is the code used for Count()
:
public int Count // Implement this one as a read-only property.
{
get
{
return _queueHigh.Count + _queueMedium.Count +
_queueLow.Count;
}
}
The “Saving PriorityQueue for last” section, earlier in this chapter, uses a simple factory object to generate an endless stream of Package
objects with randomized priority levels. At long last, that simple class can be revealed:
class PackageFactory
{
//A random-number generator
Random _randGen = new Random(2);
//CreatePackage -- The factory method selects a random priority,
// then creates a package with that priority.
// Could implement this as iterator block.
public Package CreatePackage()
{
// Return a randomly selected package priority.
// Need a 0, 1, or 2 (values less than 3).
int rand = _randGen.Next(3);
// Use that to generate a new package.
// Casting int to enum is clunky, but it saves
// having to use ifs or a switch statement.
return new Package((Priority)rand);
}
}
Class PackageFactory
has one data member and one method. (You can just as easily implement a simple factory as a method rather than as a class — for example, a method in class Program
.) When you instantiate a PackageFactory
object, it creates an object of class Random()
and stores it in the data member _randGen
. To obtain a value between 0 and 2 to store in rand
as a priority, the code calls _randGen.Next(3)
. A value of 0 represents a high-priority item.
To generate a randomly prioritized Package
object, you call your factory object's CreatePackage()
method like this in Main()
:
Package pack;
PackageFactory fact = new PackageFactory();
… Additional setup code …
for (int i = 0; i < numToCreate; i++)
{
Console.Write(" Generating and adding random package {0}", i);
pack = fact.CreatePackage();
Console.WriteLine(" with priority {0}", pack.Priority);
pq.Enqueue(pack);
}
CreatePackage()
tells its random-number generator to generate a number from 0 to 2 (inclusive) and uses the number to set the priority of a new Package
, which the method returns (to a Package
variable). The resulting Package
is then queued to the PriorityQueue
, pq
.
Programmers have long known that they should avoid tight coupling. (One of the more decoupled approaches is to use the factory indirectly via an interface, such as IPrioritizable
, rather than a concrete class, such as Package
.) Programmers still create objects directly all the time, using the new
operator, and that's fine. But factories can make code less coupled — and therefore more flexible. Here’s a version of the code from the “Using PackageFactory” section that relies on IPrioritizable
(note the need for a cast during queuing):
IPrioritizable pack;
PackageFactory fact = new PackageFactory();
… Additional setup code …
for (int i = 0; i < numToCreate; i++)
{
Console.Write(" Generating and adding random package {0}", i);
pack = fact.CreatePackage();
Console.WriteLine(" with priority {0}", pack.Priority);
pq.Enqueue((Package)pack);
}
PriorityQueue
must be able to ask an object what its priority is. To make it work, all classes that are storable in PriorityQueue
must implement the IPrioritizable
interface, as Package
does. Package
lists IPrioritizable
in its class declaration heading, like this:
class Package : IPrioritizable
Then it implements
's IPrioritizable
property.Priority
class PriorityQueue<T> where T : IPrioritizable
Did you notice the where
clause earlier? This boldfaced where
clause specifies that T
must implement IPrioritizable
. That's the enforcer. It means, “Make sure that T
implements the IPrioritizable
interface — or else!”
T
must derive from (or be).T
must implement, as shown in the previous example.https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters
provides more details.Note the struct
and class
options in particular. Specifying struct
means that T
can be any value type: a numeric type, a char
, a bool
, or any object declared with the struct
keyword. Specifying class
means that T
can be any reference type: any class type.
TABLE 8-1 Generic Constraint Options
Constraint | Meaning | Example |
---|---|---|
MyBaseClass | T must be, or extend, MyBaseClass. | where T: MyBaseClass |
IMyInterface | T must implement IMyInterface. | where T: IMyInterface |
struct | T must be any value type. | where T: struct |
class | T must be any reference type. | where T: class |
new() | T must have a parameterless constructor. | where T: new() |
These constraint options give you quite a bit of flexibility for making your new generic class behave just as you want. And a well-behaved class is a pearl beyond price. You aren't limited to just one constraint, either. Here’s an example of a hypothetical generic class declared with multiple constraints on T
:
class MyClass<T> : where T: class, IPrioritizable, new()
{ … }
In this line, T
must be a class, not a value type; it must implement IPrioritizable
; and it must contain a constructor without parameters. Strict!
class MyClass<T, U> : where T: IPrioritizable, where U: new()
You see two where
clauses, separated by a comma. The first constrains T
to any object that implements the IPrioritizable
interface. The second constrains U
to any object that has a default (parameterless) constructor.
In C#, variables have a default value that signifies “nothing” for that type. For int
s, double
s, and other types of numbers, it's 0
(or 0.0
). For bool
, it's false
. And, for all reference types, such as Package
, it's null
. As with all reference types, the default for string
is null
.
But because a generic class such as PriorityQueue
can be instantiated for almost any data type, C# can't predict the proper null
value to use in the generic class’s code. For example, if you use the Dequeue()
method of PriorityQueue
, you may face this situation: You call Dequeue()
to get a package, but none is available. What do you return to signify “nothing”? Because Package
is a class type, it should return null
. That signals the caller of Dequeue()
that there was nothing to return (and the caller must check for a null
return value).
return default(T); // Return the right null for whatever T is.
This line tells the compiler to look at T
and return the right kind of null
value for that type. In the case of Package
, which as a class is a reference type, the right null
to return is, well, null
. But, for some other T
, it may be different, and the compiler can figure out what to use.
All fourth-generation languages support some kind of variance. Variance has to do with types of parameters and return values:
If you look at a method like the following one:
public static void WriteMessages()
{
List<string> someMessages = new List<string>();
someMessages.Add("The first message");
someMessages.Add("The second message");
MessagesToYou(someMessages);
}
and then you try to call MessagesToYou()
as you did earlier in this chapter with a string type
public static void MessagesToYou(IEnumerable<object> theMessages)
{
foreach (var item in theMessages)
Console.WriteLine(item);
}
This code compiles because IEnumerable<T>
is covariant — you can use a more derived type as a substitute for a higher-order type. The next section shows a real example.
A scheduling application could have Events
, which have a date, and then a set of subclasses, one of which is Course
. A Course
is an Event
. Each course knows its own number of students and the methods used to interact with them. One of these methods is MakeCalendar()
:
public void MakeCalendar(IEnumerable<Event> theEvents)
{
foreach (Event item in theEvents)
{
Console.WriteLine(item.WhenItIs.ToString());
}
}
Pretend that it makes a calendar. For now, all it does is print the date to the console. MakeCalendar
is system-wide, so it expects some enumerable list of events.
The application also has an EventSorter
class that you can pass into a list's Sort()
method. That method then uses the EventSorter
object's Compare()
method to decide which item should come first in the sorted list. Here is the EventSorter
class:
class EventSorter : IComparer<Event>
{
public int Compare(Event x, Event y)
{
return x.WhenItIs.CompareTo(y.WhenItIs);
}
}
The event manager makes a list of courses, sorts them, and then makes a calendar. ScheduleCourses
creates the list of courses and then calls courses.Sort()
with an EventSorter
as an argument, as shown here:
public void ScheduleCourses()
{
List<Course> courses = new List<Course>()
{
new Course(){NumberOfStudents=20,
WhenItIs = new DateTime(2021, 2, 1)},
new Course(){NumberOfStudents=14,
WhenItIs = new DateTime(2021, 3, 1)},
new Course(){NumberOfStudents=24,
WhenItIs = new DateTime(2021, 4, 1)},
};
// Pass an ICompare<Event> class to the List<Course> collection.
// It should be an ICompare<Course>, but it can use ICompare<Event>
// because of contravariance
courses.Sort(new EventSorter());
// Pass a List of courses, where a List of Events was expected.
// We can do this because generic parameters are covariant
MakeCalendar(courses);
}
But wait, this is a list of courses that calls Sort()
, not a list of events. Doesn't matter — IComparer<Event>
is a contravariant generic for T
(its return type) as compared to IComparer<Course>
, so it's still possible to use the algorithm.
Now the application passes a list into the MakeSchedule
method, but that method expects an enumerable collection of Events
. Parameters are covariant for generics, so it's possible to pass in a List
of courses
because Course
is covariant to Event
.
There is another example of contravariance, using parameters rather than return values. If you have a method that returns a generic list of courses
, you can call that method expecting a list of Events
, because Event
is a superclass of Course
.
You know how you can have a method that returns a String
and assign the return value to a variable that you have declared an object? Now you can do that with a generic collection, too.
In general, the C# compiler makes assumptions about the generic type conversion. As long as you're working up the chain for parameters or down the chain for return types, C# will just magically figure the type out.
The application now passes the list into the MakeSchedule
method, but that method expects an enumerable collection of Events
. Parameters are covariant for generics, so it's possible to pass in a List
of courses
because Course
is covariant to Event
. This is covariance for parameters. You can read more about covariance and contravariance for generic types at https://docs.microsoft.com/en-us/dotnet/standard/generics/covariance-and-contravariance
.