C# (.NET 8 LTS) track

Lesson 13 · Part 3: Collections & LINQ

Generics

Write reusable, type-safe generic code and understand how variance works.

Part 3: Collections & LINQ4 min readFree lesson

All types with <T>, such as List<int> and Dictionary<string, int>, are generic. Generics are a powerful mechanism that “works on any type, but is locked to one type when used.” You can achieve code reuse and type safety at the same time.

Learning Objectives

  • Define and use generic classes and methods
  • Know why and how to constrain type parameters with where
  • Understand covariance (out) and contravariance (in).

Key concepts

1) Generic classes

<T> is a placeholder meaning “a type that has not yet been determined.” It is determined at the time of use.

public class Box<T>
{
    public T? Value { get; set; }
}

var intBox = new Box<int> { Value = 42 };
var strBox = new Box<string> { Value = "hello" };

If you use T instead of object, Boxing/Casting is not needed and the type is checked at compile time.

2) Generic methods

public static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}

int m = Max(3, 7);          //T is inferred to be int
string s = Max("a", "z");

Most compilers will infer T. When specified, Max<int>(3, 7).

3) Constraints (where)

pharmaceutical Meaning
where T : class Reference type only (string, class)
where T : struct Value type only (int, bool, structure)
where T : new() Requires parameterless constructor
where T : IComparable<T> implement that interface
where T : SomeBase SomeBase or its children

Combining multiple: where T : class, new() (reference type + default constructor).

4) Covariance (out) and contravariance (in)

Simply put, it is an option that "allows casting between generic types".

  • IEnumerable<out T>: IEnumerable<Dog> can be used as IEnumerable<Animal> (covariant, only for output).
  • Action<in T>: Action<Animal> can be used as Action<Dog> (converse, when only receiving input).

For now, just remember, “There is this.” Detailed usage is an advanced topic.

See as example

Example 1 — GenericClass : Definition/Use of Box<T>

var intBox = new Box<int> { Value = 42 };
var strBox = new Box<string> { Value = "hello" };

Console.WriteLine($"intBox: {intBox.Value}");
Console.WriteLine($"strBox: {strBox.Value}");

public class Box<T>
{
    public T? Value { get; set; }

    public void Show() => Console.WriteLine($"Value in Box: {Value}");
}

Execution result

intBox: 42
strBox: Hello

Memo: Code duplication is eliminated as all types can be contained in Box<T> defined once. ? in T? indicates that Value may initially be null.

Example 2 — GenericMethod : Type inference with Max<T>

Console.WriteLine(Max(3, 7));
Console.WriteLine(Max("apple", "banana"));
Console.WriteLine(Max(3.14, 2.71));

static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}

Execution result

7
banana
3.14

Note: int, string, and double all implement IComparable<T>, so they satisfy the constraint. If CompareTo is positive, a is larger.

Example 3 — Comparing Constraint : where constraints

//class + new() constraint → new instance can be created
static T Create<T>() where T : class, new() => new T();

//struct constraints → value types only
static T DoubleIt<T>(T x) where T : struct
{
    Console.WriteLine($"Received value: {x}");
    return x;
}

Person p = Create<Person>();
p.Name = "exponent";
Console.WriteLine($"Newly created by: {p.Name}");

DoubleIt(42);
DoubleIt(3.14);
//DoubleIt("String");  ← Compilation error: string is not a struct

public class Person
{
    public string Name { get; set; } = "";
}

Execution result

Newly created by: Jisoo
Received value: 42
Received value: 3.14

Note: The new() constraint allows calling new T(). The struct constraint blocks invalid types at compile time.

Example 4 — Variance: A glance at covariability and contravariance

//IEnumerable<out T> — covariant (using Dog 's collection as Animal collection)
IEnumerable<Dog> dogs = [new Dog("Baduki"), new Dog("Poppy")];
IEnumerable<Animal> animals = dogs;   //OK : out thanks
foreach (Animal a in animals)
{
    Console.WriteLine($"Animal: {a.Name}");
}

//Action<in T> — Counterchange (Animal handling functions can be used in Dog)
Action<Animal> describe = a => Console.WriteLine($"The name is {a.Name}");
Action<Dog> describeDog = describe;   //OK: thanks to
describeDog(new Dog("chocolate"));

public class Animal(string name)
{
    public string Name { get; } = name;
}

public class Dog(string name) : Animal(name);

Execution result

Animal: Baduki
Animal: Poppy
The name is chocolate

Note: You can safely replace Output Only (out) with a more general type and Input Only (in) with a more specific type. The classes in this example use primary constructor syntax.

Common Mistakes

  1. I tried to call new T(), but there was a compilation error because there were no restrictions on new().
  2. Confusing where T : IComparable (no generic) with where T : IComparable<T>.
  3. Static fields in generic classes exist separately for each type — Box<int>.X and Box<string>.X are different variables.
  4. Overusing <T> everywhere — In places where only one type is used, the plain type is more readable.
  5. Attempting to attach the out/in keyword to a class — transformation applies only to interfaces and delegates.

Cleanup

  • Generics take types as parameters and create reusable type-safe code.
  • where constraints can limit and guarantee the capabilities of T.
  • out is covariant (output), in is covariant (input) — Interface/Delegate only.
  • Standard collections such as List<T> and Dictionary<K,V> are all the fruits of generics.

Try it yourself

cd src/GenericClass
dotnet run

cd ../GenericMethod
dotnet run

cd ../Constraint
dotnet run

cd ../Variance
dotnet run

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/Constraint/Constraint.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Coll13</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/Constraint/Program.csC#
#nullable enable

//new() constraint → new T() can be called
Person p = Create<Person>();
p.Name = "exponent";
Console.WriteLine($"Newly created by: {p.Name}");

//struct constraints → only value types are allowed
DoubleIt(42);
DoubleIt(3.14);
//DoubleIt("String");  // Compilation error: string is not a struct

static T Create<T>() where T : class, new() => new T();

static T DoubleIt<T>(T x) where T : struct
{
    Console.WriteLine($"Received value: {x}");
    return x;
}

public class Person
{
    public string Name { get; set; } = "";
}
src/GenericClass/GenericClass.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Coll13</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/GenericClass/Program.csC#
#nullable enable

//Once defined, Box<T> can contain any type.
var intBox = new Box<int> { Value = 42 };
var strBox = new Box<string> { Value = "hello" };

Console.WriteLine($"intBox: {intBox.Value}");
Console.WriteLine($"strBox: {strBox.Value}");

intBox.Show();
strBox.Show();

public class Box<T>
{
    public T? Value { get; set; }

    public void Show() => Console.WriteLine($"Value in Box: {Value}");
}
src/GenericMethod/GenericMethod.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Coll13</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/GenericMethod/Program.csC#
#nullable enable

//T is automatically inferred when called
Console.WriteLine(Max(3, 7));
Console.WriteLine(Max("apple", "banana"));
Console.WriteLine(Max(3.14, 2.71));

static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}
src/Variance/Program.csC#
#nullable enable

//IEnumerable<out T> is common → Dog collection can be used as Animal collection
IEnumerable<Dog> dogs = [new Dog("Baduki"), new Dog("Poppy")];
IEnumerable<Animal> animals = dogs;     //OK thanks to out
foreach (Animal a in animals)
{
    Console.WriteLine($"Animal: {a.Name}");
}

//Action<in T> is the opposite → Animal processing function can be used in Dog
Action<Animal> describe = a => Console.WriteLine($"The name is {a.Name}");
Action<Dog> describeDog = describe;     //OK thanks to in
describeDog(new Dog("chocolate"));

public class Animal(string name)
{
    public string Name { get; } = name;
}

public class Dog(string name) : Animal(name);
src/Variance/Variance.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Coll13</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Assignment

Problem 1 — Implementing the generic stack yourself

  • Reference solution: shown below
  • Core concept: Generic class, using List<T> inside

Requirements

  • Define the MyStack<T> class. With List<T> inside it gives:
    • void Push(T item)
  • T Pop() — If empty, throw InvalidOperationException
  • T Peek() — If empty, InvalidOperationException
  • int Count (Property)
  • Create two MyStack<int> and MyStack<string> and check their operation.

Expected output

=== int stack ===
Push: 1, 2, 3
Peek: 3
Pop: 3
Pop: 2
Number remaining: 1
=== string stack ===
Push: A, B
Pop: B
Pop: A

Hint

  • The last element of List<T> becomes the If-you-use/remove stack.
  • Access the last element with list[^1].

Problem 2 — Generic Swap<T> method

  • Reference solution: shown below
  • Key concepts: generic methods, ref parameters

Requirements

  • Write static void Swap<T>(ref T a, ref T b).
  • Calls for two variables int and two variables string and outputs the results.

Expected output

Before exchange: a=10, b=20
After exchange: a=20, b=10
Before exchange: s1=hello, s2=world
After exchange: s1=world, s2=hello

Hint

  • The ref parameter is passed by adding the ref keyword to the call measure.
  • A one-line temporary variable is enough: T tmp = a; a = b; b = tmp;

Check the correct answer

After solving it yourself, compare it with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

Homework01/Homework01.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Coll13</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework01/Program.csC#
#nullable enable

Console.WriteLine("=== int stack ===");
MyStack<int> ints = new();
ints.Push(1);
ints.Push(2);
ints.Push(3);
Console.WriteLine("Push: 1, 2, 3");
Console.WriteLine($"Peek: {ints.Peek()}");
Console.WriteLine($"Pop: {ints.Pop()}");
Console.WriteLine($"Pop: {ints.Pop()}");
Console.WriteLine($"Remaining Count: {ints.Count}");

Console.WriteLine("=== string stack ===");
MyStack<string> strs = new();
strs.Push("A");
strs.Push("B");
Console.WriteLine("Push: A, B");
Console.WriteLine($"Pop: {strs.Pop()}");
Console.WriteLine($"Pop: {strs.Pop()}");

public class MyStack<T>
{
    private readonly List<T> _items = new();

    public int Count => _items.Count;

    public void Push(T item) => _items.Add(item);

    public T Pop()
    {
        if (_items.Count == 0)
        {
            throw new InvalidOperationException("The stack is empty.");
        }
        T top = _items[^1];
        _items.RemoveAt(_items.Count - 1);
        return top;
    }

    public T Peek()
    {
        if (_items.Count == 0)
        {
            throw new InvalidOperationException("The stack is empty.");
        }
        return _items[^1];
    }
}
Homework02/Homework02.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Coll13</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework02/Program.csC#
#nullable enable

int a = 10, b = 20;
Console.WriteLine($"Before exchange: a={a}, b={b}");
Swap(ref a, ref b);
Console.WriteLine($"After exchange: a={a}, b={b}");

string s1 = "hello", s2 = "world";
Console.WriteLine($"Before exchange: s1={s1}, s2={s2}");
Swap(ref s1, ref s2);
Console.WriteLine($"After exchange: s1={s1}, s2={s2}");

static void Swap<T>(ref T a, ref T b)
{
    T tmp = a;
    a = b;
    b = tmp;
}

Learn this lesson with a mentor

Work through the C# (.NET 8 LTS) track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now