C# Programming · Lesson 8

Buying Generic

Write type-safe reusable C# code with generic collections, custom generic classes, constraints, default(T), factories, and variance.

  • csharp
  • generics
  • type-parameters
  • constraints
  • priority-queue
  • 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> replaces T.
  • Compare generic and nongeneric collections in type safety, casting, and boxing cost.
  • Use is and as to 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 where constraint and explain what the constraint unlocks.
  • Use an interface such as IPrioritizable to 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> and IComparer<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 in T for 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 int values into a nongeneric collection takes roughly 20 times as long.
  • Boxing can also cause subtle bugs.
  • Generic collections eliminate boxing because a List<int> stores int values 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.
}
  • Priority enum: the allowed priority values (Lesson 10 covers enums fully).
  • IPrioritizable interface: 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 on Package specifically.

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.
  • Priority chooses the storage tier, and all three queues enforce the same T.
  • The default case 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;
}
  • TopQueue centralizes 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). With Low, Medium, High, 0 is Low and 2 is High.
  • Enum conversion: casting the integer to Priority avoids an if or switch.

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: TryDequeue returns false when the queue is empty and hands back both the element and its priority through out parameters.
  • 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
  • MakeCalendar only reads Event values, so a sequence of Course values fits.
  • EventSorter can 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

  1. Rewrite an ArrayList of mixed values as a List<Student>, and show which lines stop compiling and which casts disappear.
  2. Build the three-queue PriorityQueue<T> for Package nongenerically, test it, then genericize it with where T : IPrioritizable.
  3. Try new PriorityQueue<int>() and read the compiler error aloud.
  4. Add a TryDequeue(out T item) method that returns false when empty instead of returning default(T).
  5. Re-create the color example with PriorityQueue<string, Preference> and compare its behavior on ties with the custom queue.
  6. Write a generic method static T First<T>(T[] items) and call it with a string[] and an int[].

Diagnose before revealing:

  • A collection stores strings, integers, and students, then casts during retrieval. (Use a generic type.)
  • Generic code must read item.Priority, but T has no known members. (Add a constraint.)
  • An empty generic queue must return “nothing.” (default(T).)
  • A method reading Event values receives IEnumerable<Course>. (Covariance.)

Common misconceptions

  • T is a placeholder for a type chosen at compile time, not a variable or a dynamic value.
  • ArrayList accepts 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 T unless a constraint guarantees them.
  • new() must be last in a constraint list, and where clauses have no colon before them or comma between them.
  • null is not valid for every T; use default(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 a List<Event>; only interfaces such as IEnumerable<T> and IComparer<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.