C# Programming · Lesson 8
Buying Generic
Write type-safe reusable C# code with generic collections, custom generic classes, constraints, default(T), factories, and variance.
Lesson purpose
Lesson 8 explains generics: fill-in-the-blanks classes, methods, interfaces, and delegates that postpone the choice of a type without postponing type checking. Students see why generic collections such as List<T> beat the older nongeneric collections such as ArrayList on both safety and performance, then follow the book’s PriorityQueue example to write a generic class of their own with a constraint, a wrapper around three internal queues, a factory, and default(T). The lesson ends with variance: when a generic type built for one class can safely stand in for one built for a related class.
Learning objectives
- Explain a type parameter and how constructing
List<int>replacesT. - Compare generic and nongeneric collections in type safety, casting, and boxing cost.
- Use
isandasto test objects retrieved from a nongeneric collection, and explain why generics make that unnecessary. - Describe the PriorityQueue rules and how a wrapper class enforces them with three internal queues.
- Write a generic class with a
whereconstraint and explain what the constraint unlocks. - Use an interface such as
IPrioritizableto promise a capability. - Explain a factory and why it reduces coupling.
- Use
default(T)to return the correct “nothing” value for any type. - Use the built-in
PriorityQueue<TElement, TPriority>. - Define covariance, contravariance, and invariance and recognize them in
IEnumerable<T>andIComparer<T>.
1. What generics are
Most nongeneric collections demand exactly the right ingredients, like a recipe that allows no substitutions. A generic is a template with a blank for a type. List<T> is a generic array-like list; to use it for integers, replace T with int:
List<int> myList = new List<int>(); // a list limited to ints
- Definition:
List<T>describes one algorithm before the element type is chosen. - Construction:
List<int>fills inTfor this object. - Reuse: the same definition works for
string,Student,BankAccount, or any single type. - Safety: every constructed type is checked by the compiler, just like an array.
T is a placeholder for a type. It is not a runtime object and has nothing to do with the dynamic keyword. Generics come in two flavors: built-in ones such as List<T>, and ones programmers write themselves.
2. Why generics: safety
When you declare an array, you state the exact element type, and the compiler rejects the wrong type at build time. A compiler error is far better than a run-time error because it is found before the program ships.
The older nongeneric collections are not type-safe. In C# every type “is an” object, because object is the base type of all value and reference types. An ArrayList stores everything as object, which has two consequences:
A cast is required to get a value back out.
ArrayList aList = new ArrayList();
// … add items …
string myString = (string)aList[4];
Anything can go in, so the cast can fail at run time.
ArrayList aList = new ArrayList();
aList.Add("a string"); // string
aList.Add(3); // int
aList.Add(aStudent); // Student
If aList[2] is a Student and the code casts it to string, the program fails while running. To be safe with a nongeneric collection, test first with is or convert with as:
if (aList[i] is Student)
{
Student theStudent = (Student)aList[i]; // safe after the test
}
Student aStudent = aList[i] as Student; // null if not a Student
if (aStudent != null)
{
// use aStudent
}
Generic collections remove all of that: they hold one declared type, reject incompatible additions at compile time, and return elements as T with no cast.
ArrayList |
List<T> |
|
|---|---|---|
| What it stores | object |
Exactly T |
| Mixed types | Allowed | Compiler error |
| Retrieval | Needs a cast that can fail | Returns T directly |
| When type errors appear | Run time | Compile time |
Quick check: where does an invalid addition fail in List<Student>?
3. Why generics: efficiency and boxing
Reference types such as string or Student go into an object collection as they are. Value types (numbers, char, bool, and structs) must be boxed: wrapped in an object on the way in and unboxed on the way out. The book’s analogy is putting each item inside an egg to fit the egg carton, then breaking the shell to get it back.
- Boxing costs time and extra memory on the heap, where reference-type objects live. Stuffing a million
intvalues into a nongeneric collection takes roughly 20 times as long. - Boxing can also cause subtle bugs.
- Generic collections eliminate boxing because a
List<int>storesintvalues directly.
Classroom emphasis: correctness and readability are usually the first benefits students notice; performance is the second.
4. Writing your own generic class
C# lets programmers write their own generic classes, collections or not. A custom generic class is instantiated just like a built-in one:
LinkedList<int> aList = new LinkedList<int>(); // built-in
MyClass<int> aClass = new MyClass<int>(); // programmer-defined
Good candidates are classes that would do the same thing for different types of data, especially collections. A signal: “I’ll probably have to write a version of this for Student objects, too.”
5. The PriorityQueue scenario
At the OOPs, Inc. shipping warehouse, packages arrive in no particular order with high (next-day), medium (second-day), or low (ground) priority. A plain queue handles turn-taking: new items join at the back and leave from the front. A priority queue adds these rules:
- High priority goes to the front of the queue, but behind any high-priority packages already there.
- Medium priority goes as far forward as possible, but behind all high-priority packages, including ones that arrive later, and behind medium-priority packages already in line.
- Low priority joins at the back and watches higher priorities move ahead.
A common design embeds several real queues inside a wrapper class:
class PriorityQueue<T> where T : IPrioritizable
{
private Queue<T> _queueHigh = new Queue<T>();
private Queue<T> _queueMedium = new Queue<T>();
private Queue<T> _queueLow = new Queue<T>();
// methods that manage the underlying queues
}
A wrapper is a class (or method) that encapsulates complexity. It must manage what goes into which underlying queue and how, and it may present an interface quite different from what is inside it (a wrapper that changes the interface is called an adapter). This wrapper deliberately looks like an ordinary queue:
Enqueue()(“N-Q”) inserts an item at the back of the correct internal queue.Dequeue()(“D-Q”) removes the item at the front of the highest-priority nonempty queue.
Using three FIFO queues keeps first-come, first-served order within each priority while always serving higher tiers first.
6. The package model: enum, interface, class
enum Priority
{
Low, Medium, High
}
interface IPrioritizable
{
Priority Priority { get; } // a read-only property in an interface
}
class Package : IPrioritizable
{
private Priority _priority;
public Package(Priority priority) => _priority = priority; // constructor
public Priority Priority
{
get => _priority;
}
// A real package would add ToAddress, FromAddress, Insurance, and so on.
}
Priorityenum: the allowed priority values (Lesson 10 covers enums fully).IPrioritizableinterface: a compiler-enforced promise that an object can report its priority.{ get; }is how a property is written in an interface (Lesson 17). Objects should be responsible for themselves, so each package knows its own priority.Package: implements the interface with a private field, a constructor that sets the priority, and a read-only property. The=>forms are expression-bodied members (Lesson 14).PriorityQueue<T>: depends on the capability (IPrioritizable), not onPackagespecifically.
7. Constraints
Generic code can use only the members it knows T has. Without help, the compiler knows nothing about T, so item.Priority would not compile. A constraint narrows what T may be so the generic code can safely do more with it:
class PriorityQueue<T> where T : IPrioritizable
{
public void Enqueue(T item)
{
Console.WriteLine(item.Priority); // allowed: T must have Priority
}
}
The where clause means “T must implement IPrioritizable.” Trying to construct PriorityQueue<int> is a compile-time error because int does not implement the interface.
| Constraint | Meaning | Example |
|---|---|---|
| Base class | T must be or derive from the class |
where T : MyBaseClass |
| Interface | T must implement the interface |
where T : IMyInterface |
struct |
T must be a value type |
where T : struct |
class |
T must be a reference type |
where T : class |
new() |
T must have a public parameterless constructor |
where T : new() |
Constraints can be combined; new() must come last:
class MyClass<T> where T : class, IPrioritizable, new()
{
}
A class can have several type parameters, each with its own where clause:
class MyClass<T, U>
where T : IPrioritizable
where U : new()
{
}
Correction to the book: its versions place a colon before where (class MyClass<T> : where T …) and a comma between the two where clauses. Neither is valid; the clauses follow the type parameters directly and are separated only by white space.
Quick check: what does the constraint unlock inside Enqueue?
8. Inside PriorityQueue
Enqueue: route by priority
public void Enqueue(T item)
{
switch (item.Priority)
{
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");
}
}
- The item keeps its concrete type through
T. Prioritychooses the storage tier, and all three queues enforce the sameT.- The
defaultcase turns an unexpected enum value into an explicit failure (exceptions are Lesson 9).
Dequeue and TopQueue: serve the highest nonempty tier
public T Dequeue()
{
Queue<T> queueTop = TopQueue();
if (queueTop != null && queueTop.Count > 0)
{
return queueTop.Dequeue();
}
return default(T); // the whole priority queue is empty
}
private Queue<T> TopQueue()
{
if (_queueHigh.Count > 0) return _queueHigh;
if (_queueMedium.Count > 0) return _queueMedium;
if (_queueLow.Count > 0) return _queueLow;
return null;
}
TopQueuecentralizes the ordering policy: high, then medium, then low.- It returns a reference to a whole private queue, but only inside the class. Never hand such a reference to outside code, or callers could bypass the wrapper’s rules and corrupt the queue.
Count
public int Count
{
get { return _queueHigh.Count + _queueMedium.Count + _queueLow.Count; }
}
The object knows how many items it holds. Resist adding public counts for each internal queue; that would reveal too much about the implementation.
default(T)
Every type has a default “nothing” value: 0 for numbers, false for bool, and null for reference types such as string and Package. Inside a generic class, the compiler cannot know which applies, so the keyword null alone will not work for every T. default(T) (or just default in current C# versions) returns the right value for whatever T is. For Package, that is null, so callers must check for null after Dequeue. A TryDequeue pattern that returns bool is often clearer.
Writing generic code the easy way
Write and test a nongeneric version for Package first. When the logic is right, replace Package with T and add the constraint.
9. Factories
Main needs many packages with random priorities for testing. A factory is a class or method that creates objects for its users:
class PackageFactory
{
Random _randGen = new Random(2);
public Package CreatePackage()
{
int rand = _randGen.Next(3); // 0, 1, or 2
return new Package((Priority)rand); // int cast to the enum
}
}
- Single responsibility: the factory owns construction choices; the queue does not know how packages are built.
- Repeatable seed:
new Random(2)starts from a fixed seed, so every run produces the same numbers. That makes testing reproducible.new Random()gives different results each run. - Valid range:
Next(3)returns 0 through 2 (the upper bound is excluded). WithLow, Medium, High, 0 is Low and 2 is High. - Enum conversion: casting the integer to
Priorityavoids aniforswitch.
Why factories matter: every time code names a specific class, it creates a dependency on that class. Many dependencies mean tightly coupled code that is hard to change. A factory isolates creation, and working through an interface such as IPrioritizable instead of Package decouples the code further. Using new directly is still normal and fine.
Touring Main
PriorityQueue<Package> pq = new PriorityQueue<Package>();
PackageFactory fact = new PackageFactory();
Random rand = new Random(2);
int numToCreate = rand.Next(20); // 0 through 19
for (int i = 0; i < numToCreate; i++)
{
Package pack = fact.CreatePackage();
Console.WriteLine($"Adding package {i} with priority {pack.Priority}");
pq.Enqueue(pack);
}
Console.WriteLine($"Packages received: {pq.Count}");
int numToRemove = rand.Next(20);
for (int i = 0; i < numToRemove; i++)
{
Package pack = pq.Dequeue();
if (pack != null)
{
Console.WriteLine($"Shipped package with priority {pack.Priority}");
}
}
With seed 2, the program creates 15 packages and then removes up to 8: all four high-priority packages ship first, followed by four medium-priority packages. Students should predict that order from the rules before running it.
10. The built-in PriorityQueue<TElement, TPriority>
C# 10 and .NET 6 include a priority queue. It takes two type parameters: the element type and the priority type.
enum Preference { First, Second, Third }
PriorityQueue<string, Preference> colors = new PriorityQueue<string, Preference>();
colors.Enqueue("Red", Preference.First);
colors.Enqueue("Orange", Preference.Third);
colors.Enqueue("Yellow", Preference.First);
colors.Enqueue("Green", Preference.Second);
while (colors.TryDequeue(out string color, out Preference pref))
{
Console.WriteLine($"{color} is {pref} preference.");
}
- Enqueue pair: each element is stored with a separate priority value.
- Lowest first: the smallest priority value (here
First, which is 0) comes out first. - Safe removal:
TryDequeuereturnsfalsewhen the queue is empty and hands back both the element and its priority throughoutparameters. - Unlike the book’s three-queue version, the built-in queue does not guarantee first-in, first-out order among items with equal priority. The book’s sample output (Red, Purple, Yellow) shows this.
The custom version remains valuable because it teaches how generics, constraints, and wrappers work.
11. Variance in generics
Variance describes when a generic type built for one class can substitute for one built for a related class. With Course deriving from Event:
| Term | Meaning | Keyword | Example |
|---|---|---|---|
| Covariance | An instance of a subclass can be used where an instance of a parent class is expected. | out |
IEnumerable<Course> used as IEnumerable<Event> |
| Contravariance | An instance of a superclass can be used where an instance of a subclass is expected. | in |
IComparer<Event> used to sort List<Course> |
| Invariance | Neither substitution is allowed. | none | List<Course> is not a List<Event> |
Covariance: producers
static void MessagesToYou(IEnumerable<object> theMessages)
{
foreach (var item in theMessages)
{
Console.WriteLine(item);
}
}
List<string> someMessages = new List<string> { "The first message", "The second message" };
MessagesToYou(someMessages); // compiles: IEnumerable<T> is covariant
A sequence that only produces string values can safely be viewed as producing object values.
Contravariance: consumers
class EventSorter : IComparer<Event>
{
public int Compare(Event x, Event y)
{
return x.WhenItIs.CompareTo(y.WhenItIs);
}
}
void MakeCalendar(IEnumerable<Event> theEvents)
{
foreach (Event item in theEvents)
{
Console.WriteLine(item.WhenItIs);
}
}
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) },
};
courses.Sort(new EventSorter()); // contravariance: an Event comparer sorts Courses
MakeCalendar(courses); // covariance: Courses supplied as Events
MakeCalendaronly readsEventvalues, so a sequence ofCoursevalues fits.EventSortercan compare any two events, so it can certainly compare two courses.- No values are converted; only the generic interface view changes.
The rule of thumb: follow the data direction. Output-only (producer, out) types can widen toward the base class; input-only (consumer, in) types can narrow toward the derived class. Mutable types such as List<T>, which both accept and return T, stay invariant. The book’s prose on this section mixes up parameters and return values in places; teach from the direction rule and the two examples.
Quick check: a method that reads Event values receives an IEnumerable<Course>. Which kind of variance makes that legal?
Classroom application
- Rewrite an
ArrayListof mixed values as aList<Student>, and show which lines stop compiling and which casts disappear. - Build the three-queue
PriorityQueue<T>forPackagenongenerically, test it, then genericize it withwhere T : IPrioritizable. - Try
new PriorityQueue<int>()and read the compiler error aloud. - Add a
TryDequeue(out T item)method that returnsfalsewhen empty instead of returningdefault(T). - Re-create the color example with
PriorityQueue<string, Preference>and compare its behavior on ties with the custom queue. - Write a generic method
static T First<T>(T[] items)and call it with astring[]and anint[].
Diagnose before revealing:
- A collection stores strings, integers, and students, then casts during retrieval. (Use a generic type.)
- Generic code must read
item.Priority, butThas no known members. (Add a constraint.) - An empty generic queue must return “nothing.” (
default(T).) - A method reading
Eventvalues receivesIEnumerable<Course>. (Covariance.)
Common misconceptions
Tis a placeholder for a type chosen at compile time, not a variable or adynamicvalue.ArrayListaccepts anything; the error appears only when a later cast fails.- Boxing applies to value types, not to reference types such as
string. - Generic code cannot call members on
Tunless a constraint guarantees them. new()must be last in a constraint list, andwhereclauses have no colon before them or comma between them.nullis not valid for everyT; usedefault(T).Random(2)produces the same sequence every run by design;Next(3)never returns 3.- The built-in
PriorityQueue<TElement, TPriority>serves the smallest priority value first and does not keep ties in arrival order. List<Course>is not aList<Event>; only interfaces such asIEnumerable<T>andIComparer<T>allow variance.- Covariance goes with producers (
out); contravariance goes with consumers (in).
Lesson summary
Generics let one definition serve many types while the compiler still checks every use. Generic collections replace fragile casts and costly boxing with compile-time safety. A custom generic class such as PriorityQueue<T> uses a type parameter, a where constraint that unlocks the members it needs, a wrapper around simpler collections, a factory for creating test objects, and default(T) for “nothing.” Variance follows data direction: producers can widen and consumers can narrow. Lesson 9 turns to exceptions: how code reports and recovers from failures.
Course Notes