Skip to main content
ANVISoftware Solutions
Lesson 34 of 62Intermediate15 min

Introducing Generics

By the end of this lesson

Explain what the type parameter in List<T> gives you.

You have written List<Order> and Dictionary<string, Product> for several lessons. The part in angle brackets is a type parameter: you are telling one general-purpose collection what kind of item it will hold, and the compiler then treats it as a collection of exactly that.

The clearest way to see what this buys is to look at how collections worked before it existed, because the problems it solved are still easy to recreate by accident.

Life before generics: everything is an object
C#
using System.Collections;

// ArrayList holds object, which means it holds anything.
ArrayList orders = new ArrayList();

orders.Add(new Order("SO-4471"));
orders.Add(new Order("SO-4472"));
orders.Add("SO-4473");             // a string, not an Order — compiles happily

foreach (object item in orders)
{
    Order order = (Order)item;     // InvalidCastException on the third item
    Console.WriteLine(order.Reference);
}
  • ArrayList stores items as object, the type every other type derives from. Anything at all can go in.
  • The third Add is a mistake, and nothing reports it. The compiler cannot object, because a string genuinely is an object.
  • Getting an item out requires a cast — the (Order) in brackets — because the collection only knows it holds objects.
  • That cast is where the mistake finally surfaces, as an InvalidCastException at run time. The error appears in the loop that reads the data, which may be in a different file, written by a different person, months after the line that put the wrong thing in.
  • Change ArrayList to List<Order> and the bad Add stops compiling. The error moves from run time to build time, and from the reader to the writer.
The second cost: boxing value types
C#
decimal amount = 1_450.00m;

object boxed = amount;              // boxing: the value is copied into an object on the heap
decimal back = (decimal)boxed;      // unboxing: copied back out again

// In an old-style collection, this happens to every single item.
ArrayList amounts = new ArrayList();

for (int i = 0; i < 100_000; i++)
{
    amounts.Add(1_450.00m);         // 100,000 separate heap allocations
}

// With a generic list, the decimals are stored as decimals.
List<decimal> betterAmounts = new List<decimal>();

for (int i = 0; i < 100_000; i++)
{
    betterAmounts.Add(1_450.00m);   // no boxing, one array of decimals
}
  • A value type such as int, decimal or bool normally lives directly where it is declared. Storing one in an object variable cannot work that way, so the runtime wraps it in a small object on the heap and copies the value in. That wrapping is boxing.
  • Every box is an allocation, and every allocation is eventually work for the garbage collector. Reading the value back means an unboxing cast and another copy.
  • List<decimal> has no need for any of this. Its internal array is an array of decimal, so values are stored directly.
  • For a handful of items none of this is measurable. For a hundred thousand in a loop it is the difference between noticeable and unnoticeable, and it was one of the main reasons generics were added to the language.
Writing your own generic type
C#
public class Repository<T> where T : class
{
    private readonly List<T> _items = new List<T>();

    public int Count => _items.Count;

    public void Add(T item)
    {
        if (item is null)
        {
            throw new ArgumentNullException(nameof(item));
        }

        _items.Add(item);
    }

    public IReadOnlyList<T> All() => _items;
}

Repository<Order> orders = new Repository<Order>();
orders.Add(new Order("SO-4471"));

Repository<Product> products = new Repository<Product>();
products.Add(new Product("Desk lamp", 1_450m, 12));

// orders.Add(new Product(...));   // does not compile, which is the point
  • The T in Repository<T> is a placeholder for a type that the caller chooses. T is a convention, not a keyword; a longer name such as TEntity reads better on types with several parameters.
  • Inside the class, T is used exactly like any other type — as a field type, a parameter type, a return type.
  • where T : class is a constraint. It restricts what callers may supply, here to reference types, and in exchange lets you rely on that inside the class. Other constraints include where T : IComparable<T> to require a member, and where T : new() to require a parameterless constructor.
  • One class definition, and Repository<Order> and Repository<Product> are two distinct types as far as the compiler is concerned. Neither will accept the other's items.

The vocabulary, now that you have seen the mechanism:

Type parameter
The placeholder in the definition — the T in List<T> or Repository<T>.
Type argument
The real type supplied at the point of use — the Order in List<Order>.
Constraint
A where clause limiting which types are acceptable, which also tells the compiler what you may do with T inside.
Open and closed
List<T> with the parameter unfilled is an open generic type. List<Order> is closed. Only closed types can be instantiated.
Type safety
The property generics restore here: mistakes about what a collection contains become compile errors rather than run-time exceptions.

Summary

  • A type parameter tells a general-purpose type what it will hold, so the compiler can check every use
  • Before generics, collections stored object, which pushed type mistakes to run time as cast failures
  • Storing a value type in an object wraps it in a heap allocation — boxing — which generics avoid entirely
  • Constraints with where narrow who may use your type and let you rely on that inside it
  • ArrayList and Hashtable are legacy; List<T> and Dictionary<TKey, TValue> replace them

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write a generic Pair<TFirst, TSecond> class that holds two values of independent types and exposes them as get-only properties.

Use it to hold a SKU and a quantity, and then a customer name and an order count. Then explain why two type parameters were needed instead of one.

Show solution

Two parameters are needed because the two values are unrelated. With a single T, a Pair<string> could only hold two strings, and pairing a string with an int would be impossible without falling back to object and losing the type safety again.

Worth knowing before you use this in real code: C# has tuples built in, so (string Sku, int Quantity) does this with no class at all, and a record is the better choice when the pair has a meaning worth naming. Pair<TFirst, TSecond> is a good exercise and rarely the right production answer, because a type called Pair tells a reader nothing about the domain.

C#
public class Pair<TFirst, TSecond>
{
    public TFirst First { get; }
    public TSecond Second { get; }

    public Pair(TFirst first, TSecond second)
    {
        First = first;
        Second = second;
    }
}

Pair<string, int> line = new Pair<string, int>("DL-1001", 3);
Pair<string, int> customerOrders = new Pair<string, int>("Priya", 12);

Console.WriteLine($"{line.First} x {line.Second}");
Console.WriteLine($"{customerOrders.First} has {customerOrders.Second} orders");

Think about it

Think about it

The ArrayList example failed with an InvalidCastException in the loop that read the data, not on the line that added the wrong item.

Why does that distance matter, and what does it cost a team in practice?

Show solution

The exception names the reader as the location of the failure, when the fault is in the writer. Whoever is debugging starts in the wrong file, and the stack trace gives them no route to the Add that caused it.

The distance can also be measured in time. The bad Add may run on a code path that is only exercised for one kind of customer, so the failure arrives weeks later, in production, from data nobody has in front of them.

With List<Order> that same mistake is a red line in the editor, before the code is even saved. The cost of fixing a compile error is seconds; the cost of diagnosing an InvalidCastException from a log file is hours. Generics do not make programs cleverer — they move a category of error to the cheapest possible moment.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

What does List<decimal> give you that an old ArrayList holding decimals does not?

Saved in this browser only.