Generics and Type Constraints

Introduction #

A generic type or method is written once and works over any type you plug into it, without giving up type safety or paying a boxing/casting cost at runtime. If you’ve ever written the same class three times with only the field type changed, generics are the tool that removes that duplication properly.

Best Practices #

  • Constrain to the minimum you need. An overly broad constraint blocks legitimate callers, an overly narrow one leaks implementation detail into the API.
  • Prefer generics over object in hot paths. Boxing value types is a real, measurable cost in per-frame or per-entity code.
  • Don’t generic-ify for its own sake. If a type only ever needs to work with one concrete type, a generic parameter is indirection with no payoff.

The problem without generics #

Before generics, a reusable container either duplicated code per type, or stored everything as object and cast on the way out, both slow (boxing value types onto the heap) and unsafe (a bad cast only fails at runtime).

// Without generics: works, but every value gets boxed and every read needs a cast.
public class ObjectStack
{
    private List<object> items = new List<object>();
    public void Push(object item) => items.Add(item);
    public object Pop()
    {
        var last = items[items.Count - 1];
        items.RemoveAt(items.Count - 1);
        return last;
    }
}

int value = (int)stack.Pop(); // cast required, fails at runtime if wrong

Generic types #

A generic type parameter (conventionally named T) is filled in at the point of use, so the compiler knows the real type at every call site, no boxing, no cast.

public class Stack<T>
{
    private List<T> items = new List<T>();
    public void Push(T item) => items.Add(item);
    public T Pop()
    {
        var last = items[items.Count - 1];
        items.RemoveAt(items.Count - 1);
        return last;
    }
}

var intStack = new Stack<int>();
intStack.Push(5);
int value = intStack.Pop(); // no cast, the compiler already knows it's an int

Type constraints #

A bare T can be anything, which means you can only call the members every type has (ToString(), Equals()). A where clause constrains T to types that guarantee more, so you can call more.

public interface IDamageable
{
    void ApplyDamage(int amount);
}

// T must implement IDamageable, so ApplyDamage is guaranteed to exist
public class DamageQueue<T> where T : IDamageable
{
    private Queue<T> targets = new Queue<T>();

    public void Enqueue(T target) => targets.Enqueue(target);

    public void ApplyAll(int amount)
    {
        foreach (var target in targets)
            target.ApplyDamage(amount);
    }
}

// T must have a public parameterless constructor
public T CreateInstance<T>() where T : new()
{
    return new T();
}

  • where T : class restricts T to reference types.
  • where T : struct restricts T to value types.
  • where T : SomeBaseClass restricts T to that class or a subclass.
  • where T : ISomeInterface restricts T to types implementing that interface.
  • where T : new() requires a public parameterless constructor.

Rate This Article!