Skip to content

C# - Generics

Generics allow you to design and write classes, methods, and interfaces that are type-safe yet flexible. They let you define a placeholder for a data type (a ‘type parameter’), which is then specified when the code is used. This allows you to write a single piece of code that can work with any data type, maximizing code reuse, performance, and type safety.

The most common example is generic collections. Instead of creating a separate IntList, StringList, and ProductList, you can use the generic List<T>, where T is the type parameter you provide: List<int>, List<string>, List<Product>.

A generic class is defined with a type parameter in angle brackets (<>). This allows the class to be instantiated for different data types while ensuring that all operations within the class are type-safe.

Example: A Simple Generic ‘Wrapper’ Class

Section titled “Example: A Simple Generic ‘Wrapper’ Class”

Let’s create a simple class that can ‘wrap’ a value of any type.

using System;
// A generic class with one type parameter, T
public class DataWrapper<T>
{
// The internal data is of type T
public T Data { get; private set; }
public DataWrapper(T data)
{
this.Data = data;
}
public void DisplayData()
{
Console.WriteLine($"Data is: {Data} (Type: {typeof(T).Name})");
}
}
// --- Usage ---
// Create a wrapper for an integer
var intWrapper = new DataWrapper<int>(123);
intWrapper.DisplayData(); // Output: Data is: 123 (Type: Int32)
// Create a wrapper for a string
var stringWrapper = new DataWrapper<string>("Hello, Generics!");
stringWrapper.DisplayData(); // Output: Data is: Hello, Generics! (Type: String)

Best Practice: Use Built-in Generic Collections

Section titled “Best Practice: Use Built-in Generic Collections”

While creating your own generic classes is powerful, you should almost always prefer the highly optimized, built-in generic collections in the System.Collections.Generic namespace. These include:

  • List<T>: The most common, a dynamically-sized list.
  • Dictionary<TKey, TValue>: A collection of key-value pairs.
  • Queue<T>: A first-in, first-out (FIFO) collection.
  • Stack<T>: A last-in, first-out (LIFO) collection.
  • HashSet<T>: A collection of unique elements.

You can also create methods that are generic, even if they are inside a non-generic class. This is useful for utility functions that perform an operation on any type.

This classic example shows a method that can swap two variables of any type.

public class Utility
{
public static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
}
// --- Usage ---
int x = 10, y = 20;
Console.WriteLine($"Before: x={x}, y={y}");
Utility.Swap(ref x, ref y); // Type T is inferred as int
Console.WriteLine($"After: x={x}, y={y}");
string s1 = "Hello", s2 = "World";
Console.WriteLine($"Before: s1={s1}, s2={s2}");
Utility.Swap(ref s1, ref s2); // Type T is inferred as string
Console.WriteLine($"After: s1={s1}, s2={s2}");

Before .NET introduced built-in generic delegates, you had to define a new delegate for every different method signature. Now, you can use Func<...> and Action<...> for nearly all cases.

  • Action<T>: Represents a method that takes one or more parameters (up to 16) and returns void.
  • Func<T, TResult>: Represents a method that takes one or more parameters and returns a value of type TResult (the last type parameter is always the return type).
// This Func delegate represents any method that takes two ints and returns an int.
Func<int, int, int> operation;
// Point it to an 'Add' method
operation = (a, b) => a + b; // Using a lambda expression
Console.WriteLine($"Sum: {operation(10, 5)}"); // Output: Sum: 15
// Point it to a 'Multiply' method
operation = (a, b) => a * b;
Console.WriteLine($"Product: {operation(10, 5)}"); // Output: Product: 50

Interfaces can also be generic. This is extremely common in modern software design, for patterns like the Repository Pattern.

This interface defines a contract for a data repository that can work with any entity type.

// The generic interface
public interface IRepository<T>
{
T GetById(int id);
IEnumerable<T> GetAll();
void Add(T entity);
}
// A concrete implementation for a 'Product' class
public class ProductRepository : IRepository<Product>
{
private readonly List<Product> _products = new();
public void Add(Product entity) => _products.Add(entity);
public IEnumerable<Product> GetAll() => _products;
public Product GetById(int id) => _products.FirstOrDefault(p => p.Id == id);
}
// (Assuming a Product class exists)
public record Product(int Id, string Name);

Sometimes you need to guarantee that the type parameter T has certain capabilities (e.g., it has a parameterless constructor or implements a specific interface). You can enforce this using constraints with the where keyword.

// This method will only accept types T that are classes and have a parameterless constructor.
public T CreateInstance<T>() where T : class, new()
{
return new T();
}
// This method requires T to implement the IDisposable interface.
public void ProcessAndDispose<T>(T item) where T : IDisposable
{
// Now we can safely call Dispose() because the constraint guarantees it exists.
item.Dispose();
}