Generics & Variance
Master type parameters, constraints, and covariance/contravariance in generic types
Generics & Variance in C#
Master C# generics with free flashcards and spaced repetition practice. This lesson covers type parameters, constraints, covariance, contravariance, and generic type safetyβessential concepts for building flexible, reusable code in modern C# applications.
Welcome to Advanced Type Safety π»
Generics revolutionized C# by enabling type-safe code reuse without sacrificing performance. Before generics, developers relied on object types or code duplication. Now, you can write a single method or class that works with any type while maintaining compile-time type checking. Variance takes this further by allowing you to use more derived or less derived types than originally specified, making your generic interfaces and delegates even more flexible.
In this lesson, you'll learn how to leverage C#'s powerful generic system to write cleaner, safer code. We'll explore type parameters, constraints, covariance (out), contravariance (in), and the subtle rules that govern variance in delegates and interfaces.
Core Concepts: Understanding Generics
What Are Generics?
Generics allow you to define classes, interfaces, methods, and delegates with type parametersβplaceholders for actual types that are specified when the generic type is used. Think of type parameters as "type variables" that get filled in later.
βββββββββββββββββββββββββββββββββββββββββββ β GENERIC TYPE DEFINITION β β β β class Boxβ β { β β public T Value { get; set; } β β } β β β β βββββββββββββββββββββββββββββββββββββ β β β β USAGE WITH SPECIFIC TYPES β β β β Box intBox = new Box (); β β Box strBox = new Box ();β β β β β Type-safe, no casting needed β β β Compile-time type checking β β β Better performance (no boxing) β βββββββββββββββββββββββββββββββββββββββββββ
Key benefits:
- Type safety: Errors caught at compile-time, not runtime
- Code reuse: Write once, use with many types
- Performance: No boxing/unboxing for value types
- IntelliSense support: Better tooling and autocomplete
Generic Type Parameters
Type parameters are declared in angle brackets <T> and can be named anything (conventionally starting with T):
T- Type (most common single parameter)TKey,TValue- Key and Value typesTResult- Result typeTInput,TOutput- Input and Output types
// Generic method
public T GetFirst<T>(T[] array)
{
return array.Length > 0 ? array[0] : default(T);
}
// Multiple type parameters
public class Dictionary<TKey, TValue>
{
// Implementation
}
π‘ Tip: Use descriptive names for type parameters when you have multipleβit makes code more readable!
Generic Constraints
Constraints limit which types can be used as type arguments. They're specified with the where keyword and enable you to call methods or access properties that aren't available on all types.
| Constraint | Syntax | Meaning |
|---|---|---|
| Class constraint | where T : ClassName | T must be or derive from ClassName |
| Interface constraint | where T : IInterface | T must implement IInterface |
| Struct constraint | where T : struct | T must be a value type |
| Class constraint | where T : class | T must be a reference type |
| New constraint | where T : new() | T must have parameterless constructor |
| Unmanaged constraint | where T : unmanaged | T must be an unmanaged type |
| Notnull constraint | where T : notnull | T must be non-nullable (C# 8+) |
// Multiple constraints
public class Repository<T> where T : class, IEntity, new()
{
public T Create()
{
return new T(); // new() constraint allows this
}
public int GetId(T entity)
{
return entity.Id; // IEntity constraint allows this
}
}
β οΈ Constraint order matters: class or struct must come first, then base class, then interfaces, then new().
Understanding Variance
Variance is about assignment compatibility between generic types when their type arguments have inheritance relationships. It answers the question: "If Dog derives from Animal, can I use List<Dog> where List<Animal> is expected?"
Invariance (Default)
By default, generic types are invariantβno conversion is allowed between Generic<Derived> and Generic<Base>, even if Derived inherits from Base.
class Animal { }
class Dog : Animal { }
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // β ERROR: Cannot convert
Why? Safety! If this were allowed:
animals.Add(new Cat()); // Would add a Cat to a List<Dog>!
Invariance prevents this type safety violation.
Covariance (out)
Covariance allows you to use a more derived type than originally specified. It's enabled with the out keyword and only works for type parameters used as output (return types).
ββββββββββββββββββββββββββββββββββββββββ β COVARIANCE FLOW β β β β IEnumerabledogs β β β β β β β Can assign to β β β β β IEnumerable animals β β β β More specific β More general β β (You can get Animals from Dogs) β ββββββββββββββββββββββββββββββββββββββββ
// IEnumerable<T> is covariant
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // β
OK: Covariance
// foreach only reads, never writes
foreach (Animal animal in animals)
{
Console.WriteLine(animal.Name);
}
Why is this safe? IEnumerable<T> only returns T valuesβit never accepts them as input. Since every Dog is an Animal, returning dogs as animals is perfectly safe.
π§ Memory device: "out of the box"βcovariant type parameters produce output but don't consume input.
Contravariance (in)
Contravariance allows you to use a less derived type than originally specified. It's enabled with the in keyword and only works for type parameters used as input (method parameters).
ββββββββββββββββββββββββββββββββββββββββ β CONTRAVARIANCE FLOW β β β β ICompareranimalCmp β β β β β β β Can assign to β β β β β IComparer dogCmp β β β β More general β More specific β β (Can compare Dogs using Animal β β comparison logic) β ββββββββββββββββββββββββββββββββββββββββ
// Action<T> is contravariant
Action<Animal> actOnAnimal = (animal) => Console.WriteLine(animal.Name);
Action<Dog> actOnDog = actOnAnimal; // β
OK: Contravariance
actOnDog(new Dog()); // Calls actOnAnimal with a Dog
Why is this safe? If a method can handle any Animal, it can certainly handle a Dog (which is an Animal). The method only consumes T values, never produces them.
π§ Memory device: "in the method"βcontravariant type parameters accept input but don't produce output.
Variance Rules Summary
π Variance Quick Reference
| Variance | Keyword | Usage | Example |
|---|---|---|---|
| Invariance | (none) | Read & write | List<T> |
| Covariance | out | Return values only | IEnumerable<out T> |
| Contravariance | in | Method parameters only | IComparer<in T> |
β οΈ Critical: Variance only applies to interfaces and delegates, not to classes or structs!
Examples with Detailed Explanations
Example 1: Building a Generic Stack
Let's create a type-safe generic stack with constraints:
public class Stack<T> where T : IComparable<T>
{
private T[] items;
private int count;
public Stack(int capacity = 10)
{
items = new T[capacity];
count = 0;
}
public void Push(T item)
{
if (count == items.Length)
Array.Resize(ref items, items.Length * 2);
items[count++] = item;
}
public T Pop()
{
if (count == 0)
throw new InvalidOperationException("Stack is empty");
return items[--count];
}
public T PeekMax()
{
if (count == 0)
throw new InvalidOperationException("Stack is empty");
T max = items[0];
for (int i = 1; i < count; i++)
{
if (items[i].CompareTo(max) > 0) // IComparable<T> constraint
max = items[i];
}
return max;
}
}
// Usage
Stack<int> numbers = new Stack<int>();
numbers.Push(5);
numbers.Push(10);
numbers.Push(3);
Console.WriteLine(numbers.PeekMax()); // Output: 10
Explanation: The IComparable<T> constraint allows us to call CompareTo() in the PeekMax() method. Without this constraint, the compiler wouldn't know that T supports comparison. This demonstrates how constraints unlock functionality while maintaining type safety.
Example 2: Covariance with Interfaces
Here's how covariance enables flexible code with read-only collections:
public interface IProducer<out T>
{
T Produce();
}
public class AnimalShelter : IProducer<Animal>
{
public Animal Produce() => new Animal { Name = "Generic Animal" };
}
public class DogKennel : IProducer<Dog>
{
public Dog Produce() => new Dog { Name = "Buddy", Breed = "Golden" };
}
public void ProcessAnimals(IProducer<Animal> producer)
{
Animal animal = producer.Produce();
Console.WriteLine($"Received: {animal.Name}");
}
// Usage
DogKennel kennel = new DogKennel();
ProcessAnimals(kennel); // β
OK: IProducer<Dog> β IProducer<Animal>
Explanation: The out keyword on IProducer<out T> makes it covariant. Since Dog derives from Animal, we can pass an IProducer<Dog> where IProducer<Animal> is expected. This is safe because Produce() only returns valuesβit never accepts T as input. The returned Dog can always be treated as an Animal.
Example 3: Contravariance with Delegates
Contravariance shines when working with event handlers and callbacks:
public class Animal
{
public string Name { get; set; }
public void MakeSound() => Console.WriteLine("Some sound");
}
public class Dog : Animal
{
public string Breed { get; set; }
}
public delegate void Handler<in T>(T item);
public class EventProcessor<T>
{
public Handler<T> Callback { get; set; }
public void Process(T item)
{
Callback?.Invoke(item);
}
}
// Usage
Handler<Animal> animalHandler = (animal) =>
{
Console.WriteLine($"Handling animal: {animal.Name}");
animal.MakeSound();
};
EventProcessor<Dog> dogProcessor = new EventProcessor<Dog>();
dogProcessor.Callback = animalHandler; // β
OK: Contravariance
dog.Processor.Process(new Dog { Name = "Max", Breed = "Labrador" });
Explanation: The in keyword on Handler<in T> makes it contravariant. A handler that can process any Animal can certainly process a specific Dog. This is safe because the delegate only consumes T as inputβit never returns a T value. Contravariance allows more general handlers to work with more specific types.
Example 4: Generic Method with Multiple Constraints
Combining constraints creates powerful, safe utility methods:
public class Repository
{
public T FindOrCreate<T>(int id, Func<int, T> factory)
where T : class, IEntity, new()
{
T existing = Database.Find<T>(id);
if (existing != null)
return existing;
// Use factory if provided, otherwise use new()
T newEntity = factory != null ? factory(id) : new T();
newEntity.Id = id; // IEntity allows this
Database.Save(newEntity); // class constraint ensures reference type
return newEntity;
}
}
public interface IEntity
{
int Id { get; set; }
}
public class User : IEntity
{
public int Id { get; set; }
public string Username { get; set; }
public User() { } // Required by new() constraint
}
// Usage
var repo = new Repository();
User user = repo.FindOrCreate<User>(123, null);
Explanation: This method showcases multiple constraints working together:
class: EnsuresTis a reference type (can be null)IEntity: Allows accessing theIdpropertynew(): Enables creating instances withnew T()
The constraints must appear in this specific order. This pattern is common in repositories and factories where you need to both create and manipulate instances.
Common Mistakes β οΈ
Mistake 1: Forgetting Variance Only Works with Interfaces/Delegates
// β WRONG: Classes cannot be variant
public class MyClass<out T>
{
// Error: Variance not valid on classes
}
// β
RIGHT: Use interfaces
public interface IMyInterface<out T>
{
T GetValue();
}
Why it matters: The C# compiler only supports variance for interfaces and delegates because they don't store state directly. Classes with covariant type parameters could violate type safety through fields.
Mistake 2: Using 'out' Parameters with Input Methods
// β WRONG: Covariant parameter in input position
public interface IProcessor<out T>
{
void Process(T item); // Error: T in input position
}
// β
RIGHT: Use invariant or contravariant
public interface IProcessor<in T>
{
void Process(T item); // OK: Contravariant
}
public interface IProcessor<T> // OK: Invariant
{
void Process(T item);
}
Why it matters: Covariance only works when T appears in output positions (return types). Using it in input positions would break type safety.
Mistake 3: Assuming List is Covariant
// β WRONG: List<T> is invariant
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // Error!
// β
RIGHT: Use IEnumerable<T> for reading
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // OK: Covariant
// β
RIGHT: Cast explicitly if you need List
List<Animal> animals = dogs.Cast<Animal>().ToList();
Why it matters: List<T> allows both reading and writing, so it must be invariant. Use IEnumerable<T> (covariant) when you only need to read, or explicitly convert when necessary.
Mistake 4: Circular Constraints
// β WRONG: Circular constraint
public class Node<T> where T : Node<T>
{
public T Next { get; set; }
}
// β
RIGHT: Self-referencing with proper design
public class Node<T> where T : Node<T>
{
public T Next { get; set; }
public T GetLast()
{
T current = (T)this;
while (current.Next != null)
current = current.Next;
return current;
}
}
Why it matters: While circular constraints can work (like the Curiously Recurring Template Pattern), they're complex and easy to misuse. Ensure your design genuinely benefits from self-referencing.
Mistake 5: Ignoring the 'new()' Constraint Order
// β WRONG: Incorrect constraint order
public class Factory<T> where T : new(), IDisposable
{
// Error: new() must come last
}
// β
RIGHT: Correct order
public class Factory<T> where T : IDisposable, new()
{
public T Create()
{
return new T();
}
}
Why it matters: The C# compiler requires constraints in a specific order: class/struct constraint first, then base class, then interfaces, then new() last. Violating this order causes compilation errors.
Did You Know? π€
Before generics (C# 1.0), developers used
ArrayListwhich stored everything asobject. This caused frequent runtime casting errors and boxing overhead. Generics were introduced in C# 2.0 (2005) and transformed the language.Variance keywords (
inandout) were added in C# 4.0 (2010), inspired by similar features in other languages like Scala.The
Func<>delegates are covariant in their return type and contravariant in their parameters:Func<in T, out TResult>. This makes them incredibly flexible for functional programming patterns.Generic type parameters can themselves be constrained by other type parameters:
where T : IComparable<U>creates interesting relationships between types.
Key Takeaways π―
β Generics enable type-safe code reuse without performance penalties from boxing or casting
β
Constraints (where) unlock functionality by guaranteeing type capabilities at compile time
β
Covariance (out) allows more derived types in output positionsβthink "producing" values
β
Contravariance (in) allows less derived types in input positionsβthink "consuming" values
β Variance only applies to interfaces and delegates, not classes or structs
β Invariance is the safe default when types are used for both input and output
β Generic methods can infer types from arguments, making them convenient to call
β Multiple constraints can combine to create powerful, safe abstractions
π Quick Reference Card: Generics & Variance
| Concept | Syntax | Use When |
|---|---|---|
| Generic class | class Box<T> | Reusable type-safe containers |
| Generic method | T Get<T>() | Type-agnostic operations |
| Class constraint | where T : BaseClass | Need base class methods |
| Interface constraint | where T : IInterface | Need interface methods |
| Struct constraint | where T : struct | Only value types allowed |
| Class constraint | where T : class | Only reference types allowed |
| New constraint | where T : new() | Need to instantiate T |
| Covariance | interface I<out T> | Return T, never accept as input |
| Contravariance | interface I<in T> | Accept T as input, never return |
| Invariance | class C<T> | Read and write T (default) |
Memory Devices:
- π§ OUTput = Covariance (produces values)
- π§ INput = Contravariance (consumes values)
- π§ "new() comes new-ly" (last in constraint order)
- π§ "Interfaces are INterchangeable" (support variance)
Further Study π
Deepen your understanding of C# generics and variance with these resources:
Microsoft Docs - Generics (C# Programming Guide)
https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics
Comprehensive official documentation with examples and best practicesMicrosoft Docs - Covariance and Contravariance
https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/
Detailed explanation of variance with real-world scenariosC# in Depth by Jon Skeet - Chapter on Generics
https://csharpindepth.com/articles/
Deep dive into the subtleties of generic type system design
Continue practicing with real code examples to master these powerful features. Generics and variance are foundational to modern C# development, from LINQ to dependency injection frameworks! π»β¨