DEV Community

Cover image for Gentle introduction for generics (C#)
Karen Payne
Karen Payne

Posted on • Updated on

Gentle introduction for generics (C#)

Introduction

Generics are the most powerful feature of C#. Generics allow you to define type-safe data structures, without committing to actual data types. This results in a significant performance boost and higher quality code because you get to reuse data processing algorithms without duplicating type-specific code.

Generics allow you to define type-safe classes without compromising type safety, performance, or productivity. You implement the server only once as a generic server, while at the same time you can declare and use it with any type.

Above from Microsoft

To do that, use the < and > brackets, enclosing a generic type parameter.

Example, to deserialize a json string the following can be used for any model.

public class JsonHelpers
{
    /// <summary>
    /// Read json from string with converter for reading decimal from string
    /// </summary>
    /// <typeparam name="T">Type to convert</typeparam>
    /// <param name="json">valid json string for <see cref="T"/></param>
    public static List<T>? Deserialize<T>(string json)
    {

        JsonSerializerOptions options = new(JsonSerializerDefaults.Web)
        {
            WriteIndented = true
        };

        return JsonSerializer.Deserialize<List<T>>(json, options);

    }
}
Enter fullscreen mode Exit fullscreen mode

To deserialize a list of Product.

public class Product
{
    public int ProductId { get; set; }
    public string ProductName { get; set; }
    public decimal? UnitPrice { get; set; }
    public short? UnitsInStock { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Usage

var json = File.ReadAllText(fileName);
var products = Deserialize<Product>(json);
Enter fullscreen mode Exit fullscreen mode

If instead there were a Customer class

Usage

var json = File.ReadAllText(fileName);
var products = Deserialize<Customer>(json);
Enter fullscreen mode Exit fullscreen mode

To many developers, learning how to best work with generics can be difficult without solid real life code samples. In this article see several code samples that go from non-generic to generic.

Most of the code samples are in Windows Forms project as using Windows Forms tends to be easier to learn from even if Windows Forms are not used. There is also one Razor Pages project which demonstrates using generics and one console project for non-generics verses generic counterparts.

In a hurry

Here is the code.

Example 1

The following code sample (a console project) uses Spectre.Console NuGet package to provide easy methods for gathering user input like first and last name of type string or perhaps birth date for a DateOnly property.

Non-generic method using Text Prompt which expects the type to obtain information to get first and last name along with birth date we need three methods.

public static string GetFirstName() =>
    AnsiConsole.Prompt(
        new TextPrompt<string>("[white]First name[/]?")
            .PromptStyle("yellow")
            .AllowEmpty());

public static string GetLastName() =>
    AnsiConsole.Prompt(
        new TextPrompt<string>("[white]Last name[/]?")
            .PromptStyle("yellow")
            .AllowEmpty());

public static DateOnly? GetBirthDate() =>
    AnsiConsole.Prompt(
        new TextPrompt<DateOnly>("What is your [white]birth date[/]?")
            .PromptStyle("yellow")
            .AllowEmpty());
Enter fullscreen mode Exit fullscreen mode

Instead let's look at a generic way, one method instead of three.

public static T GetInput<T>(string text) => 
    AnsiConsole.Prompt(new TextPrompt<T>($"[white]{text}[/]?")
        .AllowEmpty()
        .PromptStyle("yellow"));
Enter fullscreen mode Exit fullscreen mode

Layout of generic

Usage

Here we pass the type to GetInput and pass in the prompt.

var firstName = Prompts.GetInput<string>("First name");
var lastName = Prompts.GetInput<string>("Last name");
var birthDate = Prompts.GetInput<DateOnly>("Birth date");
Enter fullscreen mode Exit fullscreen mode

Example 2

In a Windows Forms project there is a CheckedListBox populated via the DataSource property with a list of Company.

public class Company
{
    public int Id { get; set; }
    public string Name { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

A novice will first type the following to get checked companies but will not return the id property.

List<Company> list = [];
for (int index = 0; index < CompaniesCheckedListBox.Items.Count; index++)
{
    if (CompaniesCheckedListBox.GetItemChecked(index))
    {
        list.Add(new Company() { Name = CompaniesCheckedListBox.Items[index].ToString()! });
    }
}
Enter fullscreen mode Exit fullscreen mode

A intermediate developer will use the following but is still tied to a type of Company.

public static class CheckedListBoxExtensions
{
    public static List<Company> CheckedCompanies(this CheckedListBox sender)
        => sender.Items.Cast<Company>()
            .Where(( _ , index) => sender.GetItemChecked(index))
            .Select(item => item)
            .ToList();
}
Enter fullscreen mode Exit fullscreen mode

If the CheckedListBox were populated with a Product class we need the following.

    public static List<Product> CheckedProducts(this CheckedListBox sender)
    => sender.Items.Cast<Product>()
        .Where((item, index) => sender.GetItemChecked(index))
        .Select(item => item)
        .ToList();
}
Enter fullscreen mode Exit fullscreen mode

In both cases, generics to the rescue!!!

public static class CheckedListBoxExtensions
{
    public static List<T> CheckedList<T>(this CheckedListBox sender)
        => sender.Items.Cast<T>()
            .Where((item, index) => sender.GetItemChecked(index))
            .Select(item => item)
            .ToList();
}
Enter fullscreen mode Exit fullscreen mode

Usage

List<Company> result = CompaniesCheckedListBox.CheckedList<Company>();

List<Product> result = ProductCheckedListBox.CheckedList<Product>();
Enter fullscreen mode Exit fullscreen mode

Abstraction from controls

In the last set of examples language extension methods were used on a specific control, a CheckedListBox. Let's change to thinking ListBox or DataGridView for a change.

The task is to save the contents to a json file. If either control DataSource is bound to a BindingList** which keeping with the above could be a list of Company or Product, instead of creating a language extension for a control we create one for a BindingList**.

public static class BindingListExtensions
{
    public static void SaveToFile<T>(this BindingList<T> sender, string FileName)
    {
        File.WriteAllText(FileName, JsonSerializer.Serialize(sender, new JsonSerializerOptions
        {
            WriteIndented = true
        }));
    }

    public static void SaveToFile1<T>(this BindingList<T> sender, string FileName)
    {

        JsonSerializerOptions options = new()
        {
            WriteIndented = true
        };

        File.WriteAllText(FileName, JsonSerializer.Serialize(sender, options));
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage

Here is does not matter what control uses the BindingList

protected BindingList<Product> _bindingListRight = new();
.
.
.
var fileName = "ProductsList.json";
_bindingListRight.SaveToFile<Product>(fileName);
Enter fullscreen mode Exit fullscreen mode

Example 3

The following class provides generic extension methods for writing and reading temp data in ASP.NET Core/Razor Pages.

public static class TempDataHelper
{
    public static void Put<T>(this ITempDataDictionary sender, string key, T value) where T : class
    {
        sender[key] = JsonSerializer.Serialize(value);
    }

    public static T Get<T>(this ITempDataDictionary sender, string key) where T : class
    {
        sender.TryGetValue(key, out var unknown);
        return unknown == null ? null : JsonSerializer.Deserialize<T>((string)unknown);
    }
}
Enter fullscreen mode Exit fullscreen mode

Rather than stepping through usage, run the project WorkingWithDateTime ASP.NET Core project which on submit on the index page post sets temp data and opens another page which reads the data back.

Consistency

Another great use for generics is consistency in naming methods in classes that perform various operations.

Example, for adding a new record, one developer writes a method AddNewRecord while another developer names the method Add. This makes it difficult to easily work with the code base. Instead use an interface to enforce, in this case method names.

Sample generic interface

public interface IOperations<T> where T : class
{
    IEnumerable<T> GetAll();
    Task<List<T>> GetAllAsync();
    T GetById(int id);
    T GetByIdWithIncludes(int id);
    Task<T> GetByIdAsync(int id);
    Task<T> GetByIdWithIncludesAsync(int id);
    bool Remove(int id);
    void Add(in T sender);
    void Update(in T sender);
    int Save();
    Task<int> SaveAsync();
}
Enter fullscreen mode Exit fullscreen mode

Going with a Company model

public class Company
{
    public int Id { get; set; }
    public string Name { get; set; }
    public override string ToString() => Name;

    // for Bogus
    public Company(int id)
    {
        Id = id;
    }

}
Enter fullscreen mode Exit fullscreen mode

Create a class for working with companies and implement IOperations.

public class CompanyOperations : IOperations<Company>
{
}
Enter fullscreen mode Exit fullscreen mode

Visual Studio will prompt to implement missing members and in return we get the following read to write code.

public class CompanyOperations : IOperations<Company>
{
    public IEnumerable<Company> GetAll()
    {
        throw new NotImplementedException();
    }

    public Task<List<Company>> GetAllAsync()
    {
        throw new NotImplementedException();
    }

    public Company GetById(int id)
    {
        throw new NotImplementedException();
    }

    public Company GetByIdWithIncludes(int id)
    {
        throw new NotImplementedException();
    }

    public Task<Company> GetByIdAsync(int id)
    {
        throw new NotImplementedException();
    }

    public Task<Company> GetByIdWithIncludesAsync(int id)
    {
        throw new NotImplementedException();
    }

    public bool Remove(int id)
    {
        throw new NotImplementedException();
    }

    public void Add(in Company sender)
    {
        throw new NotImplementedException();
    }

    public void Update(in Company sender)
    {
        throw new NotImplementedException();
    }

    public int Save()
    {
        throw new NotImplementedException();
    }

    public Task<int> SaveAsync()
    {
        throw new NotImplementedException();
    }
}
Enter fullscreen mode Exit fullscreen mode

Same goes for other models.

Using constraints

Constraints inform the compiler about the capabilities a type argument must have. Without any constraints, the type argument could be any type.

Examples taken from provided source code. All extension methods must implement INumber<T>.

So we can only use the following extension methods on numerics. And if this is not understood than a developer might use GetFractionalPart3 which uses int.Parse which not necessary, instead simply do a conversion using Convert.ToInt32 as done in GetFractionalPart2. The point here is to understand that if there is a possibility of null we use Convert.

public static class Extensions
{
    // Get fractional part of a number as an integer
    public static int GetFractionalPart1<T>(this T sender) where T : INumber<T>
    {
        int value = (int)(decimal.CreateChecked(sender) % 1 * 100);
        return int.IsNegative(value) ? value.Invert() : value;
    }

    // Get fractional part of a number as an integer
    public static int GetFractionalPart2<T>(this T sender) where T : INumber<T>
    {
        var value = Convert.ToInt32((decimal.CreateChecked(sender) % 1)
            .ToString(CultureInfo.InvariantCulture).Replace("0.", ""));
        return int.IsNegative(value) ? value.Invert() : value;
    }

    // Get fractional part of a number as an integer
    public static int GetFractionalPart3<T>(this T sender) where T : INumber<T>
    {
        var value = int.Parse((decimal.CreateChecked(sender) % 1)
            .ToString(CultureInfo.InvariantCulture).Replace("0.", ""));
        return int.IsNegative(value) ? value.Invert() : value;
    }


    // Get fractional part of a number as a decimal
    public static decimal GetFractionalPart<T>(this T sender, int places) where T : INumber<T>
        => Math.Round(decimal.CreateChecked(sender) - 
                      Math.Truncate(decimal.CreateChecked(sender)), places);

    public static T Invert<T>(this T source) where T : INumber<T>
        => -source;
}
Enter fullscreen mode Exit fullscreen mode

Summary

Generics should be used when it makes sense, not just to use generics. Take time to study the code provided in the GitHub repository, step through the code to get a better understanding of what has been presented. Once comfortable consider places that can benefit from using generics.

Code provided with this article goes beyond what has been presented so take time to study all of the code.

Source code

Clone the following GitHub repository which has code written using Microsoft Visual Studio 2022 NET8 Framework.

See also

Gentle introduction to Generic Repository Pattern with C#

Top comments (0)