Generic Types

Write one class that works for any type, with no casting and no boxing, and see why List<T> beats a list of object.

Syntaxclass Box<T> { public T Value { get; set; } } // used as Box<int>, Box<string>, ...

A generic type is a type with a blank left in it. You write the class once, and whoever uses it fills the blank with a real type. List<T> is the familiar one: the T in angle brackets is the blank, and List<int> fills it with int.

T is called a type parameter. The type supplied at the point of use is the type argument. A generic class with its blank filled in, such as List<int>, behaves like any other type.

Why not a list of object

Every type derives from object, so a collection of object can hold anything. That was the only option before C# 2 added generics. It costs two things. Everything coming out needs a cast back to its real type, and the compiler cannot check that cast, so a wrong one fails only when the program runs. And storing a value type such as int in an object slot copies it onto the heap. That copy is called boxing, and it happens once per item.

List<int> stores int values directly. Nothing is boxed, nothing is cast, and adding a string to it will not compile.

Shorthand

These three declarations mean the same thing. The second lets the compiler repeat the type from the left-hand side. The third is a collection expression, added in C# 12.

List<int> a = new List<int>();
List<int> b = new();
List<int> c = [];

What trips people up

Box<int> and Box<string> are two separate types. They share source code, not identity, so neither converts to the other, and neither converts to Box<object>.

Example

Example · csharp
// A box that holds any one value. T is a placeholder for a real type.
Box<string> nameBox = new Box<string>("Ada");
Box<int> ageBox = new Box<int>(36);

// No cast is needed; the compiler already knows what comes out.
string name = nameBox.Value;
int age = ageBox.Value;
Console.WriteLine($"{name} is {age}");

// The old way, before generics: hold the value as object.
ObjectBox loose = new ObjectBox("Ada");
// The value has to be cast back, and the compiler cannot check the cast.
string loosened = (string)loose.Value;
Console.WriteLine(loosened.ToUpperInvariant());

class Box<T>
{
    public Box(T value)
    {
        Value = value;
    }

    public T Value { get; }
}

class ObjectBox
{
    public ObjectBox(object value)
    {
        Value = value;
    }

    public object Value { get; }
}

// Output:
// Ada is 36
// ADA

When to use it

  • Write one cache class that can hold sessions, invoices or images without casting on the way out.
  • Replace a List<object> that needs a cast on every read with a List<Invoice>.
  • Hold two related values, such as a key and a count, in one small reusable type.
  • Store a collection of int or double without boxing every element.

More examples

A list of object against a list of int

Both halves store the same number. The object version needs a cast on every read, lets a string slip in beside the numbers, and boxes each int onto the heap. The generic version rejects the string while compiling and stores the ints directly. Reach for this comparison when you meet older code built on List<object> or ArrayList and need to say what replacing it buys.

Example · csharp
// Before generics, a list of anything meant a list of object.
List<object> loose = new List<object>();
loose.Add(1);                  // an int held as object is copied to the heap: boxing
loose.Add("not a number");     // nothing stops this

int first = (int)loose[0];     // the cast is required, and it can throw

List<int> tight = new List<int>();
tight.Add(1);
// tight.Add("not a number");  // error CS1503, caught while compiling
int alsoFirst = tight[0];      // no cast, no boxing

Console.WriteLine($"{first} and {alsoFirst}");
Console.WriteLine($"loose holds {loose.Count}, tight holds {tight.Count}");

// Output:
// 1 and 1
// loose holds 2, tight holds 1

Two type parameters

A generic type can have as many type parameters as it needs, and they are independent of each other. The convention is to name a single one T, and to give a descriptive name beginning with T when there are several, as the library does with Dictionary<TKey, TValue>. The second declaration shows the shorthand: new(3, 4) means the same as new Pair<int, int>(3, 4), because the compiler reads the type from the left-hand side.

Example · csharp
// Two blanks. The names say what each one is for.
Pair<string, int> score = new Pair<string, int>("Ada", 36);
Console.WriteLine(score.Describe());

// Target-typed new: identical to new Pair<int, int>(3, 4).
Pair<int, int> point = new(3, 4);
Console.WriteLine(point.Describe());

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

    public TFirst First { get; }
    public TSecond Second { get; }

    public string Describe() => $"({First}, {Second})";
}

// Output:
// (Ada, 36)
// (3, 4)

A generic class is a family of types

Writing Counter<T> declares a family of types, not one type. The runtime builds a separate Counter<string> and Counter<int>, each with its own static members. Know this before you put a cache, a counter or a lazily built lookup in a static field of a generic class, because you will get one per type argument rather than one in total.

Example · csharp
// Each type argument produces its own type, with its own static state.
Counter<string> words = new Counter<string>();
words.Add("one");
words.Add("two");

Counter<int> numbers = new Counter<int>();
numbers.Add(7);

// Total is static, so there is one Total for Counter<string> and a
// different one for Counter<int>. They are unrelated types.
Console.WriteLine(Counter<string>.Total);
Console.WriteLine(Counter<int>.Total);

class Counter<T>
{
    public static int Total { get; private set; }

    private readonly List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
        Total++;
    }
}

// Output:
// 2
// 1

Discussion

  • Be the first to comment on this lesson.