Demystify Generics in C#

Demystify Generics in C#

A general purpose code that is type safe at compile time.

If you have a written couple hundred lines of code then you have probably used generics knowingly or unknowingly. The concept of generic is simple, a general-purpose block of code that can handle type during compile time. It was introduced in C# version 2.0 and was a major feature. Collections was the reason it was introduced. List<string>, List<int>, List<Dictionary<key, value>>, etc., might look familiar, well they are all generic collection types. Here is the List<T> class (decompiled) from the System.Collections.Generic namespace. Under the hood, List<T> is actually an dynamic array. The list is initially empty and has a capacity of zero. Upon adding the first element to the list the capacity is increased to DefaultCapacity, and then increased in multiples of two as required.

public class List<T> : IList<T>, IList, IReadOnlyList<T>
{
    private const int DefaultCapacity = 4;

    internal T[] _items; 
    internal int _size; 
    internal int _version; 

    private static readonly T[] s_emptyArray = new T[0];

    // Constructs a List. The list is initially empty and has a capacity
    // of zero. 
    public List()
    {
        _items = s_emptyArray;
    }

    // Constructs a List with a given initial capacity. The list is
    // initially empty, but will have room for the given number of elements
    // before any reallocations are required.
    public List(int capacity)
    {
        if (capacity < 0)
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);

        if (capacity == 0)
            _items = s_emptyArray;
        else
            _items = new T[capacity];
    }

    // Constructs a List, copying the contents of the given collection. The
    // size and capacity of the new list will both be equal to the size of the
    // given collection.
    //
    public List(IEnumerable<T> collection)
    {
        if (collection == null)
            ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);

        if (collection is ICollection<T> c)
        {
            int count = c.Count;
            if (count == 0)
            {
                _items = s_emptyArray;
            }
            else
            {
                _items = new T[count];
                c.CopyTo(_items, 0);
                _size = count;
            }
        }
        else
        {
            _items = s_emptyArray;
            using (IEnumerator<T> en = collection!.GetEnumerator())
            {
                while (en.MoveNext())
                {
                    Add(en.Current);
                }
            }
        }
    }

    // methods like: Add(T item), AddRange(IEnumerable<T> collection), 
    // Insert(int index, T item), Remove(T item), RemoveAt(int index), 
    // RemoveAll(Predicate<T> match), Count(), Contains(T item), ...

}

So when a List<int> is initialized, the compiler is replacing all of T in List<T> class including all of its method with int of List<int>. The compiler automatically determines the type of the generic type parameter T in List<T>, a process often referred to as type inference. Type inference is done without explicit casting and providing type safety.

Now, it's time to craft our very own generic class. Imagine we have a ware house where we can store clothes and shoes. To start, we'll define a Cloth class.

public class Cloth
{
    private string _type { get; set; }
    private int ? _price { get; set; }

    public Cloth(string type, int price)
    {
       _type = type;
       _price = price;
    }
}

Following that, we'll define a Shoe class.

public class Cloth : IProductService
{
    private string _type { get; set; }
    private int? _price { get; set; }

    public Cloth(string type, int price)
    {
        _type = type;
        _price = price;
    }
}

Then, we'll create a versatile warehouse class capable of offering flexibility for various storage needs either for cloth or shoe.

public class Warehouse<T>
{
    private List<T> _items;

    public Warehouse()
    {
        _items = new List<T>();
    }

    public void AddItem(T item)
    {
        _items.Add(item);
        Console.WriteLine("Item added successfully.");
    }

    public void RemoveItem(T item)
    {
        _items.Remove(item);
        Console.WriteLine("Item removed.");
    }

    public void PrintTotalItems()
    {
        int itemsCount = _items.Count;
        Console.WriteLine($"Total items in the ware house: {itemsCount}");
    }
}

Let's now get to the business. I would like to create a Warehouse to store Clothes. Before storing cloth in the warehouse, I need some clothes.

Cloth shirt = new("denim jeans", 600);
Cloth jacket = new("leather", 1200);
Cloth sweater = new("woolen", 1800);

Now, a warehouse to store cloth.

Warehouse<Cloth> clothesWareHouse = new();

Store clothes in the warehouse.

clothesWarehouse.AddItem(shirt);
clothesWarehouse.AddItem(jacket);
clothesWarehouse.AddItem(sweater);

Check status of the clothe warehouse. Then, remove an item from the warehouse.

clothesWarehouse.PrintTotalItems();
Console.WriteLine("-- Remove an item from the warehouse. --");
clothesWarehouse.RemoveItem(sweater);
clothesWarehouse.PrintTotalItems();

Output:

Now, discover the true impact of Generics in action. Let's build a warehouse to store Shoes. Before storing, I need some shoes.

Shoe highTop = new("converse", 42, 900);
Shoe boot = new("gucci", 43, 3400);
Shoe lowTop = new("air max 90", 42, 4500);

A warehouse to store them.

Warehouse<Shoe> shoeWarehouse = new();

Store shoes in the warehouse.

shoeWarehouse.AddItem(highTop);
shoeWarehouse.AddItem(boot);
shoeWarehouse.AddItem(lowTop);

Check status of the shoe warehouse. Then, remove 2 items from the warehouse.

shoeWarehouse.PrintTotalItems();
Console.WriteLine("-- Remove 2 items from the warehouse. --");
shoeWarehouse.RemoveItem(highTop);
shoeWarehouse.RemoveItem(boot);
shoeWarehouse.PrintTotalItems();

Output:

We used the same Warehouse to store clothes and shoes. What did we achieve? Well, we didn't create a separate class for ware house. By using generics, we've created a versatile Warehouse class that can adapt to different types of items without the need for separate implementations for clothes and shoes.

We achieved:

  • Code re-usability : The Warehouse adapts to various item types, providing the freedom to store clothes, shoes, or any other compatible items without rewriting the code.

  • Flexibility : The Warehouse can store different types of items like clothes, shoes, and any other compatible items without needing to change the code.

  • Reduced redundancy : Generics cut out the need for repeating code across different classes, eliminating the necessity for duplicate code in separate classes for different types.

  • Maintainability : Centralizing logic improves maintainability, enhancing the overall manageability of our code.

Type Constraints

Here are some problems with our plain, non-restricted generic class. What if someone wants to store string in a warehouse like Warehouse<string> or any other type. Warehouse is specifically for a "Product" which might not make sense for a warehouse to store type other than "Product". By introducing type constraints to ensure that only items of the specified type ("Product" in our scenario) can be added to the warehouse. Type constraint enhance type safety and prevent such potential runtime errors. We will introduce an interface IProductService, which will be implemented by any "Product". Let's construct IProductService and add a method that display product details.

public interface IProductService 
{
   void DisplayProductDetails();
}

Implement IProductService in our product types.

public class Cloth : IProductService
{
    private string _type { get; set; }

    private int? _price { get; set; }

    public Cloth(string type, int price)
    {
       _type = type;
       _price = price;
    }

    public void DisplayProductDetails()
    {
        Console.WriteLine($"A cloth product of type:{_type}");
    }
}
public class Shoe : IProductService
{
    private string _brand { get; set; }
    private int _size { get; set; }
    private int ? _price { get; set; }

    public Shoe(string brand, int size, int? price)
    {
        _brand = brand;
        _size = size;
        _price = price;
    }

    public void DisplayProductDetails()
    {
        Console.WriteLine($"A shoe product of brand: {_brand} and of size: {_size}");
    }
}

Now, to make Warehouse to store only "Product" type, we will introduce a type constraint in our Warehouse<T> generic class.

public class Warehouse<T> where T : IProductService
{
    private List<T> _items;

    public Warehouse()
    {
        _items = new List<T>();
    }

    public void AddItem(T item)
    {
        _items.Add(item);
        Console.WriteLine("Item added successfully.");
    }

    public void RemoveItem(T item)
    {
        _items.Remove(item);
        Console.WriteLine("Item removed.");
    }

    public void TotalItems()
    {
        int itemsCount = _items.Count;
        Console.WriteLine($"Total items in the ware house: {itemsCount}");
    }

    public void DisplayAllProductDetails()
    {
        foreach(T item in _items)
        {
            item.DisplayProductDetails();
        }
    }
}

In Warehouse class with a type parameter T, we added DisplayAllProductsDetails() method to print product detail. The where T : IProductService constraint specifies that the generic type T must implement the IProductService interface. Warehouse cannot be accept type beside IProductService.

Below, since the Cloth type implements IProductService, Warehouse can store them.

// Create some clothes
Cloth shirt = new("denim jeans", 600);
Cloth jacket = new("leather", 1200);
Cloth sweater = new("woolen", 1800);

// create warehouse to store clothes
Warehouse<Cloth> clothesWarehouse = new();
clothesWarehouse.AddItem(shirt);
clothesWarehouse.AddItem(jacket);
clothesWarehouse.AddItem(sweater);
clothesWarehouse.TotalItems();
Console.WriteLine("-- Remove an item from the warehouse. --");
clothesWarehouse.RemoveItem(sweater);
clothesWarehouse.TotalItems();
clothesWarehouse.DisplayAllProductDetails();

Output:

If we try to store other than IProductService type in our generic Warehouse, we will get compile-time error. Let's look at this by creating a type of Person.

public class People
{
    private string Name { get; set; }
    private int Age { get; set; }
    public People(string name, int age)
    {
        Name = name;

        Age = age;
    }
}

If I try to create Warehouse to store People :

Type constraints ensure the allowance of only certain types, enhancing code safety by preventing inappropriate types from being used and catching potential errors at compile-time rather than runtime. In our case, the IProductService constraint in Warehouse<T> ensures that only IProductService types can be stored in our warehouse.

Generic methods

In addition to generic classes, C# design teams also introduced generic methods. Generic methods offers a powerful way to write functions that work with a variety of data types without sacrificing type safety. They are commonly used in algorithms and collection manipulation functions, where the type of data being processed can vary. Let's dive into an example. We will create a class that will provide us basic services of accounting. We will add a specific method to check if the product is expensive or not.

public class AccountingService
{
    public AccountingService() { }

    /// <summary>
    /// Method to return consumption percentage.
    /// </summary>
    /// <returns></returns>
    public int GetConsumptionPercentage(int totalItem, int currentItem)
    {
        return totalItem/currentItem * 100;
    }

    /// <summary>
    /// Is expensive if more than 1000.
    /// </summary>
    /// <returns></returns>
    public bool IsProductExpensive<T>(T product) where T : IProductService
    {
        return product.GetProductPrice() > 1000;
    }
}

Here, IsProductExpensive<T>(T product) is an generic method that will return the product expensiveness. For sake of type safety, only IProductService type can be checked. I added GetProductPrice() in IProductService. So, each type that implements IProductService will have GetProductPrice(). Cloth and Shoe class will have a method, something like this:

public int GetProductPrice()
{
    return _price ?? 0;
}

Our generic method returns bool. Let's print if a particular cloth is expensive.

Cloth sweater = new("woolen", 1800);
// check if sweater is expensive. Our logic insists > 1000 to be expensive.
AccountingService accountingService = new AccountingService();
var isExpensive = accountingService.IsProductExpensive(sweater);
Console.WriteLine($"Is product expensive:{isExpensive}");

Output:

More generic constraints

C# supports many generic constraints. Following are some of the type constraint:

  • new() : The new() constraint in the Warehouse<T> class specifies that the generic type parameter T must have a public parameterless constructor. This means that you can create an instance of T without providing any constructor arguments. However, it doesn't restrict the use of types that have other constructors; it just ensures the existence of a parameterless constructor.

    Syntax to use new() constraint:

      public class Warehouse<T> where T : new()
      {
         // methods
      }
    

Warehouse now can be used by only type that have parameterless constructor. If we recall our Cloth.cs, Shoe.cs, and People.cs, and want to initialize them, they all have public parameterized constructor. Hence, Warehouse<T> where T : new() cannot be used, we will get an compile-time error. They must be have public parameterless parameter.

  • Interface <interface name> : Interface constraint ensures generic type parameter to be implemented by the provided interface. Our original Warehouse uses interface type constraint.

    Syntax to use interface type constraint:

      public class Warehouse<T> where T : IProductService
      {
         // methods
      }
    
  • Class name (<base class>) : The class name constraint, also known as the base class constraint, ensures that a generic type parameter must be derived from or be the provided class. Our original Warehouse uses interface type constraint.

    Syntax to use base class type constraint:

      public class Warehouse<T> where T : SomeBaseClass
      {
         // methods
      }
    
  • notnull : notnull constraint ensures the generic type parameter to be a non-nullable reference or value type.

    Syntax to use notnull type constraint:

      public class Warehouse<T> where T : notnull
      {
         // methods
      }
    

Generic Variance

Generic variance refers to the ability to use a more specific type than originally specified (covariance) or a less specific type than originally specified (contravariance).

Covariance

Covariance allows you to use a more derived type than originally specified. For example, we can create a general Warehouse to store our product (both shoe and cloth). We can achieve this by initializing a Warehouse<IProductService>.

// create shoe
Shoe ggSneaker = new("gucci", 42, 4400);
Shoe airMax = new("air max 90", 42, 4500);
// create cloth
Cloth winterCloth = new("hoodie", 5500);
Cloth winterCloth1 = new("sweater", 1400);
/* covariance example */
Warehouse<IProductService> covarianceWarehouse = new();
// add cloth type 
covarianceWarehouse.AddItem(winterCloth);
covarianceWarehouse.AddItem(winterCloth1);
// add shoe type
covarianceWarehouse.AddItem(ggSneaker);
covarianceWarehouse.AddItem(airMax);
// print total items of covariance ware house
covarianceWarehouse.DisplayAllProductDetails();

Output:

Covariance allows you to treat a Warehouse<Derived> as a Warehouse<Base>, where Derived is a type derived from Base. Here, IProductService serves as the common base interface for both Cloth and Shoe, allowing to use covariance. The out keyword is used to explicitly declare covariance when dealing with generic types in interfaces and delegates. It provides a way to communicate the intent of the generic parameter usage. We can modify our IProductService like so:

public interface IProductService<out T> 
{
    void DisplayProductDetails();
    int GetProductPrice();
}

The out keyword indicates covariance for the generic type parameter T in interfaces or delegates. It allows the type parameter to be used as a more derived (specific) type than originally specified, providing flexibility in scenarios where a more specific type is expected. Now, we need to modify all our IProductService derived classes like so:

Shoe.cs

public class Shoe : IProductService<object>
{
   // methods and properties
}

Cloth.cs

public class Cloth : IProductService<object>
{
   // methods and properties
}

Warehouse.cs

public class Warehouse<T> where T : IProductService<object>
{
   // methods and properties
}

Main method

// create shoe
Shoe ggSneaker = new("gucci", 42, 4400);
Shoe airMax = new("air max 90", 42, 4500);
// create cloth
Cloth winterCloth = new("hoodie", 5500);
Cloth winterCloth1 = new("sweater", 1400);
/* covariance example */
Warehouse<IProductService<object>> covarianceWarehouse = new(); // using object as type argument
// add cloth type 
covarianceWarehouse.AddItem(winterCloth);
covarianceWarehouse.AddItem(winterCloth1);
// add shoe type
covarianceWarehouse.AddItem(ggSneaker);
covarianceWarehouse.AddItem(airMax);
// print total items of covariance ware house
covarianceWarehouse.DisplayAllProductDetails();

Output:

Using object as a type parameter can be a solution in some cases, but it comes with a trade-off. When you use object as the generic type parameter, you sacrifice type safety and lose the benefits of working with specific types. Using "object" doesn't check type, so errors are only discovered at runtime which is a headache when debugging. In our scenario, we want to create a Warehouse that can store various types of products (clothes and shoes), it might be more beneficial to create a non-generic interface. Hence, it is not compulsory to use out keyword to achieve covariance.

Contravariance

Contravariance allows you to use a more base type than originally specified. Contravariance is exactly on the flip side of covariance. For example, with contravariance, you can treat a Warehouse<Product> as a Warehouse<Shoe>. However, you can only add shoes to it, not clothes.

// Contravariant use of Warehouse
Warehouse<Shoe> contravariantWarehouse = new();
contravariantWarehouse.AddItem(partyShoe);
contravariantWarehouse.AddItem(runningShoe);
//contravariantWarehouse.AddItem(nightWear); // Compile time error
contravariantWarehouse.DisplayAllProductDetails();

Here, we created an warehouse of type shoe. We cannot add other than shoe in contravariantWarehouse.

Output:

In this continuation, I've added the creation of a Shoe instance for partyShoe, runningShoe, and a Cloth instance for nightWear. As mentioned, attempting to add nightWear to contravariantWarehouse will result in a compile-time error since contravariantWarehouse is specifically typed to only accept Shoe instances.

Contravariance provides flexibility when dealing with generic types, allowing you to treat a more specific type as a more general type. This can be useful in scenarios where you want to handle a broader range of types while maintaining type safety.

Summary

  1. Generics in C#:

    • Introduced in C# version 2.0 for handling types at compile time.

    • Used for creating general-purpose code that works with different types.

    • Commonly used in collections like List<T>, providing type safety and code reusability.

  2. Generic Classes:

    • Example of a generic class: Warehouse<T> capable of storing various types of products.

    • Achieves code reusability, flexibility, reduced redundancy, and maintainability.

    • Compiler determines the type of the generic parameter during compilation.

  3. Achievements of using generics:

    • Code reusability: Warehouse adapts to various item types.

    • Flexibility: Warehouse stores different types without code modification.

    • Reduced redundancy: Generics eliminate the need for duplicate code.

    • Maintainability: Centralized logic improves code manageability.

  4. Type Constraints:

    • Introduced to enforce specific conditions on generic types.

    • Example: Using an interface constraint (IProductService) to ensure only specific types can be stored in the warehouse.

  5. Generic Methods:

    • Allows writing functions that work with a variety of data types without sacrificing type safety.

    • Example: AccountingService with a generic method IsProductExpensive<T> using type constraints.

  6. More Generic Constraints:

    • new() constraint ensures a public parameterless constructor.

    • Interface and base class constraints restrict types to those implementing a specific interface or derived from a base class.

    • notnull constraint ensures a non-nullable reference or value type.

  7. Generic Variance:

    • Covariance allows using a more derived type than originally specified.

    • Contravariance allows using a more base type than originally specified.

    • Example: Using covariance in a Warehouse<IProductService> to store both shoes and clothes.

  8. Conclusion:

    • Generics in C# provide a powerful mechanism for creating flexible and reusable code.

    • Type constraints enhance type safety and prevent inappropriate types at compile-time.

    • Generic variance (covariance and contravariance) allows more flexibility in handling types.

Sourcecode.