Exploring Advanced Generics in C#: A Practical Guide with Examples
Generics in C# are one of the most powerful features of the language, enabling developers to write flexible, type-safe, and reusable code. This article aims to provide a guided tour of some advanced topics in C# generics, such as constraints, covariance and contravariance, and custom generic interfaces.
- Generic Constraints
- Generic Constraints with Multiple Types
- Covariance and Contravariance
- Custom Generic Interfaces
- Generic Methods
- Generic Collections Customization
1. Generic Constraints
We use generic constraints to restrict the types that can be used as .
arguments for a generic type parameter.
For example, let’s say my company has employees and products, and I need different printing functions to print employee and product information, respectively.
By using constraints, I can ensure that each printing function only accepts the correct class.
Now, let’s take a look at my printing classes for employees and products.
public class PrintEmployeeInformation<T> where T : class, IEmployee
{
public void DisplayIdAndName(T Entity)
{
Console.WriteLine($"Employee ID: {Entity.Id} , Employee Name: {Entity.Name}, Description: {Entity.Description}");
}
}
public class PrintProductInformation<T> where T : class, IProduct
{
public void DisplayIdAndName(T Entity)
{
Console.WriteLine($"Product ID: {Entity.Id} , Product Name: {Entity.Name}, Description: {Entity.Description}");
}
}
As you can see, I set a constraint for the employee printing class to only accept types that implement the IEmployee
interface, and similarly, the product printing class only accepts types that implement the IProduct
interface.
For example, let’s say I have different employee classes, but both implement the IEmployee
interface. Since they meet the constraint, both classes can be passed into the PrintEmployeeInformation
class.
public class EmployeeIT : IEmployee
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public void Programming()
{
Console.WriteLine("I can do programming");
}
}
public class EmployeeFinance : IEmployee
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public void FInanceReport()
{
Console.WriteLine("I can do finance report");
}
}
//Main program
EmployeeIT emp = new EmployeeIT
{
Id = 101,
Name = "Alice",
Description = "IT Department"
};
EmployeeFinance emp2 = new EmployeeFinance
{
Id = 102,
Name = "Bob",
Description = "Finance Department"
};
PrintEmployeeInformation<EmployeeIT> printer1 = new PrintEmployeeInformation<EmployeeIT>();
printer1.DisplayIdAndName(emp);
PrintEmployeeInformation<EmployeeFinance> printer2 = new PrintEmployeeInformation<EmployeeFinance>();
printer2.DisplayIdAndName(emp2);
Output:
Same for product
public class Product : IProduct
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public double Price { get; set; }
}
Product product = new Product
{
Id = 2001,
Name = "T-Shirt",
Description="Green Colour",
Price=30.0
};
//Main program
PrintProductInformation<Product> printer3 = new PrintProductInformation<Product>();
printer3.DisplayIdAndName(product);
Output:
It wouldn’t work if I passed a class that doesn’t meet the constraint. This OOP feature ensures the code is safer and helps prevent unpredictable errors.
2. Generic Constraints with Multiple Types
Let’s try to add more than one constraint.
First, I create Employee
class, Manager
class that implement Employee
class, and an Iwork
interface and the CodingClass
that implement it.
public class Employee
{
public string Name { get; set; }
}
public class Manager : Employee
{
public int TeamSize { get; set; }
}
public interface IWork
{
void PerformWork();
}
public class CodingTask : IWork
{
public void PerformWork()
{
Console.WriteLine("Writing code...");
}
}
This is generic class that has multiple constraints.
public class Organization<TEmployee, TWork>
where TEmployee : Employee, new()
where TWork : IWork
{
public TEmployee Leader { get; set; }
public TWork Task { get; set; }
public Organization()
{
Leader = new TEmployee(); // Using parameterless constructor constraint
}
public void AssignTask()
{
Console.WriteLine($"{Leader.Name} is assigning the task.");
Task.PerformWork();
}
}
- It require two generic parameters
TEmployee
andTWork
- where
TEmployee
must implemetEmployee
andnew()
indicates it must have no parameter - where
TWork
must implemetIWork
And the main program
var org = new Organization<Manager, CodingTask>
{
Leader = new Manager { Name = "Alice", TeamSize = 5 },
Task = new CodingTask()
};
org.AssignTask();
Console.ReadLine();
//Output: Alice is assigning the task.
If I created another class of InternStaff
that do not implement anything, another class AccountingTask
that do not implement IWork
.
public class InternStaff
{
public int TeamSize { get; set; }
}
public class AccountingTask
{
public void PerformWork()
{
Console.WriteLine("Do some accounting report...");
}
}
So these classes will not able to pass as arguments to Organization
class, because they do not meet the constraints, it will cause a syantax error.
3. Covariance and Contravariance
So, in simple English,
- Covariance allows you to use a more derived type than originally specified.
- Contravariance allows you to use a less derived type than originally specified.
Let see how’s miserable life is without Covariance and Contravariance.
Imagine in a company, Manager
is derived from base Employee
.
Without Covariance
public class Employee
{
public string Name { get; set; }
}
public class Manager : Employee//Manager derived from Employee
{
public string Department { get; set; }
}
Then, we have ManagerPrinter
and EmployeePrinter
, both accept specific parameter and print information for certain role.
void EmployeePrinter(List<Employee> employees)
{
foreach (var employee in employees)
{
Console.WriteLine(employee.Name);
}
}
void ManagerPrinter(List<Employee> employees)
{
foreach (var employee in employees)
{
Console.WriteLine(employee.Name);
}
}
It will not compile because of type is not correct.
var managers = new List<Manager>
{
new Manager { Name = "Alice", Department = "HR" },
new Manager { Name = "Bob", Department = "IT" }
};
EmployeePrinter(managers);
With Covariance
But with covariance, we can use IEnumerable<T>
, since List<T>
is inherited from IEnumerable<T>
. So, we can change the method to
void CovariancePrinter(IEnumerable<Employee> employees)
{
foreach (var employee in employees)
{
Console.WriteLine(employee.Name);
}
}
//In main program
var managers = new List<Manager>
{
new Manager { Name = "Alice", Department = "HR" },
new Manager { Name = "Bob", Department = "IT" }
};
CovariancePrinter(managers);//print Alice \nBob
Without Contravariance
I have a PrintEmployeeInfo
that accept Employee
, derived class Manager
are not able to pass into it.
public static void PrintEmployeeInfo(Employee employee)
{
Console.WriteLine($"Employee: {employee.Name}");
}
// This will not work
// PrintEmployeeInfo(new Manager { Name = "Alice", Department = "HR" });
It can be solved by change the method to contravariant to accept Employee or any derived type.
With Contravariance
public static void PrintInfo<T>(T obj) where T : Employee
{
if (obj is Manager manager)
{
Console.WriteLine($"Manager: {manager.Name}, Department: {manager.Department}");
}
else
{
Console.WriteLine($"Employee: {obj.Name}");
}
}
You may realize both my example of covariance and contravariance are quite the same, you may confuse it with each other. This is because they are similar concepts but just work opposite directions.
A simple rule of thumb will look like this
Key Differences between Covariance and Contravariance:
- Covariance allows you to assign a more derived type to a more generic or base type. It applies to return types or output types.
- Contravariance, on the other hand, allows you to assign a more generic or base type to a more derived type. It applies to input parameters.
4. Custom Generic Interfaces
Suppose we have two classes Product
and Customer
. We need to use repository class to store those entities.
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
We created Interface for each entity, IProductRepository
and ICustomerRepository
.
public interface IProductRepository
{
void Add(Product product);
Product GetById(int id);
IEnumerable<Product> GetAll();
void Update(Product product);
void Delete(int id);
}
public interface ICustomerRepository
{
void Add(Customer customer);
Customer GetById(int id);
IEnumerable<Customer> GetAll();
void Update(Customer customer);
void Delete(int id);
}
Then the code implementation of each Interface
public class ProductRepository : IProductRepository
{
private readonly List<Product> _products = new();
public void Add(Product product) => _products.Add(product);
//...other implementation codes
// Implement the ICustomer repository
public class CustomerRepository : IRepository<Customer>
{
//...other implementation codes
Or, we can create one generic Interface of repository.
public interface IRepository<T>
{
// Add a new item to the repository
void Add(T item);
// Get an item by its ID
T GetById(int id);
// Get all items
IEnumerable<T> GetAll();
// Update an existing item
void Update(T item);
// Remove an item by its ID
void Delete(int id);
}
And the implementation
// Implement the IRepository for Product
public class GenericProductRepository : IRepository<Product>
{
private readonly List<Product> _products = new();
public void Add(Product item) => _products.Add(item);
public Product GetById(int id) => _products.FirstOrDefault(p => p.Id == id);
// other codes...
}
// Implement the IRepository for Customer
public class GenericCustomerRepository : IRepository<Customer>
{
//...other implementation codes
//Main Program
GenericProductRepository<Product> productRepository= new GenericProductRepository();
productRepository.Add(new Product { Id = 1, Name = "Laptop", Price = 1500.00m });
productRepository.Add(new Product { Id = 2, Name = "Phone", Price = 800.00m });
foreach (var product in productRepository.GetAll())
{
Console.WriteLine($"ID: {product.Id}, Name: {product.Name}, Price: {product.Price}");
}
So this is the benefit of custom generic interfaces, you can avoid code duplication by created a lot Interfaces. Adding new entities (e.g., Supplier
, Invoice
) becomes easier because you only implement the repository methods for the new type without altering the IRepository<T>
interface.
5. Generic Methods
Let say I have a two methods that switch integer and string
void SwapInteger(ref int x , ref int y)
{
int temp = x;
x = y;
y = temp;
}
void SwapString(ref string x,ref string y)
{
string temp = x;
x = y;
y = temp;
}
The codes will expand if I have new request to swap different data type like decimal, double, long etc.
I can using generic method to code a single method that works for any data type.
void GenericSwap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
//Main Program
int x = 1; int y = 2;
string a = "Hello", b="World";
GenericSwap(ref x, ref y);
GenericSwap(ref a, ref b);
Console.WriteLine("After Swap");
Console.WriteLine($"x: {x}, y: {y}");
Console.WriteLine($"a: {a}, b: {b}");
6. Generic Collections Customization
We can also customize the C# collection.
Consider following code using ArrayList
, we need to check and cast the object type before print.
public class Employee
{
public string Name { get; set; }
}
//Main program
ArrayList employees = new ArrayList();
// Adding different types of objects
employees.Add(new Employee { Name = "Alice" });
employees.Add("Invalid Entry"); // No type safety
foreach (var item in employees)
{
// Type casting required
if (item is Employee emp)
{
Console.WriteLine(emp.Name);
}
}
Or we can use customize generic collection. Create a class EmployeeList
that implement List<Employee>
, so we can ensure this customized collection only accept Employee
object.
class EmployeeList : List<Employee>
{
// Add custom behavior or constraints if needed
public void DisplayAll()
{
foreach (var emp in this)
{
Console.WriteLine(emp.Name);
}
}
}
// Main Program
EmployeeList employeesGeneric = new EmployeeList();
// Type-safe addition
employeesGeneric.Add(new Employee { Name = "Alice" });
employeesGeneric.Add(new Employee { Name = "Bob" });
// employeesGeneric.Add("Invalid Entry");//Invalid, compile error
employeesGeneric.DisplayAll();
The sample source code of this article can be found from my Github