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);
}
}
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; }
}
Usage
var json = File.ReadAllText(fileName);
var products = Deserialize<Product>(json);
If instead there were a Customer class
Usage
var json = File.ReadAllText(fileName);
var products = Deserialize<Customer>(json);
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());
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"));
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");
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; }
}
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()! });
}
}
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();
}
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();
}
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();
}
Usage
List<Company> result = CompaniesCheckedListBox.CheckedList<Company>();
List<Product> result = ProductCheckedListBox.CheckedList<Product>();
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));
}
}
Usage
Here is does not matter what control uses the BindingList
protected BindingList<Product> _bindingListRight = new();
.
.
.
var fileName = "ProductsList.json";
_bindingListRight.SaveToFile<Product>(fileName);
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);
}
}
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();
}
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;
}
}
Create a class for working with companies and implement IOperations.
public class CompanyOperations : IOperations<Company>
{
}
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();
}
}
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;
}
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.