<< Home

Chapter 9: Generics

Generics let you write flexible, type-safe code without giving up performance. By lifting algorithms and structures over type parameters you reduce duplication and improve clarity. This chapter explores generic types, type constraints, variance, and the design of reusable generic APIs.

Generic types and methods

A generic type or method introduces type parameters inside angle brackets. These type parameters act like placeholders for concrete types. At compile time the compiler substitutes real types and verifies that your usage is type safe.

Declaring generic types

Types often become generic when they need to store or operate on elements of any type. The following example defines a simple box that wraps an arbitrary value. This supports strong typing without needing casts.

public class Box<T>
{
  private T _value;
  public Box(T value) { _value = value; }
  public T Value { get { return _value; } set { _value = value; } }
}

Type parameters can appear in properties, methods, and nested types. Use descriptive names like TItem or TKey when it improves clarity.

Generic methods

Methods can introduce type parameters independently of the containing type. This makes algorithms reusable even in non-generic classes. Type inference often selects the type parameter based on arguments.

public static class Util
{
  public static T Echo<T>(T value)
  {
    return value;
  }
}

var s = Util.Echo("hello");
var i = Util.Echo(42);
💡 Keep type parameter lists short and meaningful; a few clear placeholders make generic code easier to read.

Type parameters and constraints

Constraints narrow the set of types allowed for a type parameter. They let you use members safely on that parameter and also communicate expectations. Add constraints with the where clause and place them after the parameter list.

Common constraints

C# supports several constraint forms. These guide both design and usage.

ConstraintMeaning
where T : classReference types only
where T : structNon-nullable value types only
where T : unmanagedBlittable types suitable for low-level work
where T : new()Requires a public parameterless constructor
where T : SomeBaseRequires T to derive from SomeBase
where T : ISomeInterfaceRequires T to implement ISomeInterface

Combining constraints helps formalize contracts and allows compilers to optimize. For example, where T : struct, IComparable<T> lets an algorithm sort lightweight value types safely.

Using constrained members

Inside a constrained generic type or method you can use the members guaranteed by the constraints. This reduces the need for reflection or dynamic dispatch.

public static T Min<T>(T a, T b) where T : IComparable<T>
{
  return a.CompareTo(b) <= 0 ? a : b;
}
⚠️ Avoid overly tight constraints; they make a generic type less reusable. Choose constraints that express the smallest correct contract.

Covariance and contravariance

Variance describes how type parameters relate across inheritance. C# supports variance for interfaces and delegates through the out and in modifiers. Covariance (out) preserves direction in assignment; contravariance (in) reverses it. This is powerful when working with generic interfaces and delegates that handle related types.

Covariance with out

Covariance allows substituting a more derived type for a less derived one. For example, IEnumerable<string> can be used where IEnumerable<object> is expected because iteration only produces values.

IEnumerable<string> names = new[] { "Ada", "Linus" };
IEnumerable<object> objs = names;  // allowed due to out T

Contravariance with in

Contravariance allows substituting a less derived type for a more derived one. This works when the consumer accepts values of the type parameter rather than producing them. Delegates that take parameters often use this.

Action<object> handleObj = o => Console.WriteLine(o);
Action<string> handleString = handleObj;  // allowed due to in T
💡 Variance applies only to generic interface and delegate type parameters; generic classes do not support variance directly.

Generic collections

The System.Collections.Generic namespace provides strongly typed collections. They avoid boxing and offer compile-time safety. Choose a collection that matches your access pattern; this keeps code tight and reliable.

Lists and dictionaries

A List<T> provides indexed access and dynamic sizing. A Dictionary<TKey,TValue> maps keys to values. These types are widely used because they balance performance and ease of use.

var list = new List<int> { 1, 2, 3 };
var map = new Dictionary<string,int> { ["one"] = 1, ["two"] = 2 };

Lists offer methods like Add, Remove, and Sort. Dictionaries offer methods like TryGetValue and an indexer for setting values.

Queues, stacks, and sets

Queues follow FIFO behavior; stacks follow LIFO behavior. HashSet<T> tracks unique items quickly. These structures make algorithms more expressive and efficient.

var q = new Queue<string>();
q.Enqueue("first");
q.Enqueue("second");
Console.WriteLine(q.Dequeue());

var seen = new HashSet<int>();
seen.Add(10);
seen.Add(10);  // ignored, already present

Custom generic collections

You can create your own collection by implementing interfaces like IEnumerable<T> or IReadOnlyList<T>. This provides integration with foreach and LINQ. Focus on consistent semantics; callers expect familiar behaviors from common interfaces.

public class PairSequence<T> : IEnumerable<T>
{
  private readonly T _a;
  private readonly T _b;
  public PairSequence(T a, T b) { _a = a; _b = b; }

  public IEnumerator<T> GetEnumerator()
  {
    yield return _a;
    yield return _b;
  }

  System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
}

Performance and allocations

Generics avoid boxing when working with value types; this reduces allocations and improves speed. When designing generics be mindful of how many objects you allocate, how often you reallocate buffers, and how many interface calls you perform. The compiler can devirtualize some constrained calls which keeps loops fast.

Boxing and generic code

Boxing occurs when a value type is treated as an object. Generic code usually avoids boxing because it uses concrete value types at JIT time. Watch for interface conversions or mixing non-generic collections.

List<int> values = new();
values.Add(42);        // no boxing
object o = values[0];  // boxing occurs here

Allocation patterns in collections

Collections resize their internal storage when capacity is exceeded. Setting an initial capacity reduces resizing costs. Reusing buffers or pooling objects can help in tight loops.

var list = new List<int>(capacity: 1024);  // reduces resizing
⚠️ Avoid excessive generic instantiations that carry large closures or large captured state. Keep lambdas and delegate captures small when they appear in hot paths.

Designing reusable generic APIs

Good generic APIs are small, expressive, and predictable. They avoid unnecessary constraints and expose type parameters where they add clarity. Favor generic interfaces for shared protocols and generic methods for standalone algorithms. Keep signatures stable so callers can rely on them.

Balancing flexibility and clarity

Keep the essential type parameters visible and tuck away others behind private helpers. Too many parameters make an API noisy; too few make it rigid. Strike a balance based on the patterns you expect clients to use.

public interface IRepository<TItem, TId>
{
  TItem Find(TId id);
  void Save(TItem item);
}

Putting it all together

When you design a generic API combine clear type parameters, minimal constraints, and predictable behaviors. This keeps your API stable even as new callers join. Generic design is a craft of small rules that add up to reusable, reliable components.