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);
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.
| Constraint | Meaning |
where T : class | Reference types only |
where T : struct | Non-nullable value types only |
where T : unmanaged | Blittable types suitable for low-level work |
where T : new() | Requires a public parameterless constructor |
where T : SomeBase | Requires T to derive from SomeBase |
where T : ISomeInterface | Requires 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;
}
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
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
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.