C# generics are a lot more powerful and subtle than just making a List<T> work for any T.
Let’s see some code, not just talk about it. Imagine you have a Repository<T> that fetches T objects.
public class Repository<T> where T : new()
{
public List<T> GetAll()
{
// Pretend this fetches from a database
var items = new List<T>();
// Add some dummy data, assuming T has a parameterless constructor
for (int i = 0; i < 3; i++)
{
items.Add(new T());
}
return items;
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Order
{
public int OrderId { get; set; }
public DateTime OrderDate { get; set; }
}
public class Program
{
public static void Main(string[] args)
{
// This works because Product has a parameterless constructor
var productRepo = new Repository<Product>();
var products = productRepo.GetAll();
Console.WriteLine($"Fetched {products.Count} products.");
// This also works for the same reason
var orderRepo = new Repository<Order>();
var orders = orderRepo.GetAll();
Console.WriteLine($"Fetched {orders.Count} orders.");
// What if T didn't have a parameterless constructor?
// Let's try with a type that requires arguments
// var stringRepo = new Repository<string>(); // This would fail at compile time due to the 'new()' constraint
}
}
The where T : new() constraint is a common starting point. It tells the compiler that any type T used with Repository must have a public parameterless constructor. This is crucial for the new T() call inside GetAll(). If you try to use Repository<string>, the compiler will immediately flag an error because string doesn’t have a public parameterless constructor. This is type safety in action – the compiler prevents runtime errors by enforcing rules at compile time.
But what if you need to specify more about T? You can use other constraints. For instance, if your repository only deals with items that can be identified by an int ID, you might want to ensure T has an Id property. C# doesn’t directly support arbitrary property constraints, but you can achieve this using interfaces.
public interface IEntityWithId
{
int Id { get; }
}
public class ProductWithId : IEntityWithId
{
public int Id { get; set; }
public string Name { get; set; }
}
// Modified Repository to use the interface constraint
public class IdEntityRepository<T> where T : IEntityWithId, new()
{
public List<T> GetByIds(IEnumerable<int> ids)
{
var items = new List<T>();
foreach (var id in ids)
{
// This is where the 'new()' constraint is used
var item = new T();
// In a real scenario, you'd fetch based on 'id' and populate 'item'
// For demonstration, we'll just assign the id if the type allows it
if (item is IEntityWithId entity)
{
// This part is tricky because we can't directly set Id on 'item' without reflection
// or a more complex setup. The 'new()' constraint is for instantiation,
// not for accessing specific properties after creation without knowing the concrete type.
// Let's adjust the example to focus on the *constraint* itself.
}
items.Add(item);
}
return items;
}
}
public class Program
{
public static void Main(string[] args)
{
// This works because ProductWithId implements IEntityWithId and has a parameterless constructor
var productRepo = new IdEntityRepository<ProductWithId>();
var productIds = new List<int> { 1, 5, 10 };
var fetchedProducts = productRepo.GetByIds(productIds);
Console.WriteLine($"Attempted to fetch {productIds.Count} products.");
// This would fail to compile:
// var orderRepo = new IdEntityRepository<Order>(); // Order does not implement IEntityWithId
}
}
The where T : IEntityWithId constraint means that any type T used with IdEntityRepository must implement the IEntityWithId interface. This guarantees that any T will have an Id property that can be accessed. Combined with new(), it ensures T is constructible and has the required interface members. This is how you enforce structural requirements on generic types.
Now, variance. This is where generics get really interesting and often confusing. Variance deals with how generic types relate to each other when their type arguments are related. C# supports covariance and contravariance for generic interfaces and delegates.
Covariance allows you to use a more derived type than originally specified. Think of it as "going up" the inheritance hierarchy. It’s denoted by the out keyword on the type parameter.
// Covariant interface: 'out T' means T flows out of the interface
public interface IEnumerable<out T> : IEnumerator<T>, IDisposable
{
// ... members that return T ...
T Current { get; }
}
// Example
public class Animal { }
public class Dog : Animal { }
public class CovarianceExample
{
public static void Demonstrate()
{
IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
// This works because of covariance: a List<Dog> can be treated as an IEnumerable<Animal>
IEnumerable<Animal> animals = dogs;
// You can iterate through 'animals' and get Dog objects, which are Animals.
// You cannot add an Animal to 'dogs' through the 'animals' reference,
// because that would violate type safety (trying to add a non-Dog Animal to a collection of Dogs).
// The 'out' keyword ensures T is only returned, not accepted as an input parameter.
Console.WriteLine("Covariance demonstrated: IEnumerable<Dog> assigned to IEnumerable<Animal>.");
}
}
The out T in IEnumerable<out T> is the key. It signifies that T is only used in "output" positions (return types of methods, read-only properties). This allows you to assign an IEnumerable<Dog> to an IEnumerable<Animal> because any Dog is an Animal. If T were used in an input position (method parameters), this assignment would be unsafe – you might try to pass a non-Dog Animal into something expecting a Dog.
Contravariance is the opposite: using a less derived type than originally specified. Think of "going down" the hierarchy. It’s denoted by the in keyword. This is common for action-like delegates.
// Contravariant delegate: 'in T' means T flows into the delegate
public delegate void Action<in T>(T obj);
// Example
public class ContravarianceExample
{
public static void ProcessAnimal(Animal animal)
{
Console.WriteLine("Processing an Animal.");
}
public static void ProcessDog(Dog dog)
{
Console.WriteLine("Processing a Dog.");
}
public static void Demonstrate()
{
// Action<Dog> expects a Dog object as input
Action<Dog> dogAction = ProcessDog;
// This works because of contravariance: an Action<Dog> can be assigned to an Action<Animal>
// Why? Because if you have a method that can accept ANY Animal (Action<Animal>),
// it can certainly accept a Dog (which is an Animal).
// The Action<Animal> reference can safely call ProcessDog if passed a Dog.
Action<Animal> animalAction = dogAction;
// Calling animalAction with a Dog object will execute ProcessDog
animalAction(new Dog());
Console.WriteLine("Contravariance demonstrated: Action<Dog> assigned to Action<Animal>.");
}
}
The in T in Action<in T> means T is only used in "input" positions. An Action<Animal> can accept any Animal. If you have an Action<Dog>, it’s more specialized and only accepts Dogs. By assigning Action<Dog> to Action<Animal>, you’re saying, "I have a function that knows how to handle Dogs; this can be used anywhere a function that handles any Animal is needed." The compiler ensures this is safe because the function ProcessDog can be safely invoked with a Dog object, and an Action<Animal> reference will only ever be passed Animal objects (which a Dog is).
The real magic of constraints and variance is how they work together to provide compile-time safety while maximizing code reuse. You can write a generic method or class that works with a wide range of types, and the compiler, guided by your constraints, ensures that operations performed within that generic code are valid for all types that satisfy those constraints.
One of the most powerful, and often overlooked, aspects of generic constraints is the ability to combine them. You can stack multiple constraints using commas. For example, where T : class, new(), IEquatable<T>. This forces T to be a reference type (class), have a parameterless constructor (new()), and be comparable to itself (IEquatable<T>). This allows you to write methods that can perform null checks, instantiate objects, and use equality operators reliably, all at compile time.
The next frontier is understanding how these concepts apply to generic methods, not just generic classes, and the subtle differences that arise.