On this page

Generics and collections in C#

14 min read TextCh. 2 — OOP in C#

Why generics?

Without generics, you would need to write a ListOfIntegers, a ListOfStrings, a ListOfProducts... Generics allow writing reusable code that works with any type, with compile-time type checking.

// Without generics — uses object, loses type safety
var listWithoutGenerics = new System.Collections.ArrayList();
listWithoutGenerics.Add(42);
listWithoutGenerics.Add("oops"); // compiles but is incorrect
int num = (int)listWithoutGenerics[0]; // manual cast required

// With generics — type-safe, no cast
var listWithGenerics = new List<int>();
listWithGenerics.Add(42);
// listWithGenerics.Add("oops"); // ← Compile error
int safeNum = listWithGenerics[0]; // no cast needed

List\

The most widely used collection in .NET. It is a dynamic array with O(1) index access:

var numbers = new List<int> { 5, 2, 8, 1, 9, 3 };

// Adding
numbers.Add(7);
numbers.AddRange(new[] { 10, 11, 12 });

// Removing
numbers.Remove(5);               // first occurrence of value
numbers.RemoveAt(0);             // by index
numbers.RemoveAll(n => n > 9);   // remove many by predicate

// Searching
int  idx  = numbers.IndexOf(8);
bool has  = numbers.Contains(8);
int? max  = numbers.Count > 0 ? numbers.Max() : null;

// Sorting
numbers.Sort();                                    // natural order
numbers.Sort((a, b) => b.CompareTo(a));            // descending

// Converting
int[]         arr = numbers.ToArray();
IReadOnlyList<int> ro = numbers.AsReadOnly();

Dictionary\

Key-value map with O(1) lookup. Keys must be unique:

// Initialization forms
var countries = new Dictionary<string, string>
{
    { "US", "United States" },
    { "CA", "Canada" },
    { "MX", "Mexico" }
};

// Alternative with indexer syntax
var capitals = new Dictionary<string, string>
{
    ["US"] = "Washington D.C.",
    ["CA"] = "Ottawa",
    ["MX"] = "Mexico City"
};

// Operations
capitals["GB"]  = "London";          // add
capitals["US"]  = "Washington D.C."; // update (same key)
bool has = capitals.ContainsKey("BR");

// Safe read with TryGetValue (no exception if missing)
if (capitals.TryGetValue("US", out string? capital))
    Console.WriteLine($"Capital: {capital}");

// GetValueOrDefault — default value if key not found
string? country = capitals.GetValueOrDefault("XX", "Unknown");

// Iteration
foreach (KeyValuePair<string, string> kvp in capitals)
    Console.WriteLine($"{kvp.Key}: {kvp.Value}");

// Deconstruction (modern C#)
foreach (var (code, name) in capitals)
    Console.WriteLine($"{code}{name}");

HashSet\

Collection of unique elements, O(1) lookup. Ideal for membership testing:

var roles = new HashSet<string> { "admin", "editor", "viewer" };

roles.Add("admin");   // already exists — no duplicate added
roles.Add("manager"); // new — added

bool isAdmin = roles.Contains("admin"); // true, O(1)

// Set operations
var requiredRoles = new HashSet<string> { "admin", "super" };
bool hasAccess = requiredRoles.IsSubsetOf(roles); // false

// UnionWith, IntersectWith, ExceptWith
var a = new HashSet<int> { 1, 2, 3, 4 };
var b = new HashSet<int> { 3, 4, 5, 6 };

var union     = new HashSet<int>(a); union.UnionWith(b);        // {1,2,3,4,5,6}
var intersect = new HashSet<int>(a); intersect.IntersectWith(b); // {3,4}
var diff      = new HashSet<int>(a); diff.ExceptWith(b);          // {1,2}

Queue\ and Stack\

// Queue<T> — FIFO (First In, First Out)
var queue = new Queue<string>();
queue.Enqueue("Task 1");
queue.Enqueue("Task 2");
queue.Enqueue("Task 3");

string next = queue.Dequeue();  // "Task 1" — removes and returns
string peek = queue.Peek();     // "Task 2" — only looks
Console.WriteLine($"In queue: {queue.Count}"); // 2

// Stack<T> — LIFO (Last In, First Out)
var history = new Stack<string>();
history.Push("/home");
history.Push("/courses");
history.Push("/courses/dotnet");

string current  = history.Pop();   // "/courses/dotnet" — removes and returns
string previous = history.Peek();  // "/courses" — only looks
Console.WriteLine($"Navigated to: {current}");

Custom generic classes

// Type constraints
public class Cache<TKey, TValue>
    where TKey   : notnull       // TKey cannot be null
    where TValue : class         // TValue is a reference type
{
    private readonly Dictionary<TKey, TValue> _store = new();
    private readonly int _maxSize;

    public Cache(int maxSize = 100) => _maxSize = maxSize;

    public void Set(TKey key, TValue value)
    {
        if (_store.Count >= _maxSize)
            throw new InvalidOperationException("Cache is full");
        _store[key] = value;
    }

    public TValue? Get(TKey key)
        => _store.TryGetValue(key, out var val) ? val : null;

    public bool TryGet(TKey key, out TValue? value)
        => _store.TryGetValue(key, out value);

    public void Invalidate(TKey key) => _store.Remove(key);
    public void Clear()              => _store.Clear();
    public int  Size                 => _store.Count;
}

// Usage
var cache = new Cache<string, string>(50);
cache.Set("token_1", "eyJhbGc...");
string? token = cache.Get("token_1");

Generic methods

// Swap two values
static void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

// Method with interface constraint
static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;

// Usage
int x = 5, y = 10;
Swap(ref x, ref y);
Console.WriteLine($"x={x}, y={y}"); // x=10, y=5

Console.WriteLine(Max(3, 7));         // 7
Console.WriteLine(Max("Alice", "Zoe")); // Zoe

IEnumerable\

The most fundamental collection interface. If a type implements IEnumerable<T>, you can iterate it with foreach and use LINQ:

// Generator with yield return — lazy evaluation
static IEnumerable<int> GenerateEvens(int limit)
{
    for (int i = 0; i <= limit; i += 2)
        yield return i;
}

foreach (int even in GenerateEvens(20))
    Console.Write($"{even} "); // 0 2 4 6 8 10 12 14 16 18 20

// Combine with LINQ
var evens = GenerateEvens(100)
    .Where(n => n % 4 == 0)
    .Take(5)
    .ToList();

Practice

  1. Inventory: Create a Dictionary<string, int> of products and quantities. Add methods to Add, Remove (checking stock), and List only items with stock > 0.
  2. Generic Stack class: Implement public class Stack<T> with Push, Pop, Peek, and Count using a List<T> internally.
  3. HashSet deduplication: Given a List<string> with duplicate email addresses, use HashSet<string> to get the unique list in a single line.

In the next lesson we will learn LINQ — the most elegant way to query, transform, and aggregate collections in C#.

Expose collections as IReadOnlyList
When a class exposes its internal collection, return IReadOnlyList<T> or IEnumerable<T> instead of List<T>. This prevents external code from modifying the internal collection directly and protects the class's invariant.
HashSet for uniqueness, SortedDictionary for order
HashSet<T> guarantees unique elements with O(1) lookup. SortedDictionary<K,V> keeps keys ordered with O(log n) lookup. Choose the collection based on the operations you perform most: frequent insert/lookup → Dictionary; guaranteed order → SortedDictionary.
where T constraints
Constraints where T: class (reference type), where T: struct (value type), where T: new() (has parameterless constructor), where T: IInterface, and where T: BaseClass help the compiler allow specific operations on T and generate more efficient code.
using System.Collections.Generic;

// ── List<T> — ordered, dynamically sized collection ──
var products = new List<string> { "Laptop", "Mouse", "Keyboard" };
products.Add("Monitor");
products.Insert(1, "Webcam");      // insert at position
products.Remove("Mouse");           // remove by value
products.RemoveAt(0);               // remove by index

Console.WriteLine($"Count: {products.Count}");
Console.WriteLine(string.Join(", ", products));

// Sorting
products.Sort();
products.Sort((a, b) => b.Length.CompareTo(a.Length)); // custom

// Searching
bool has    = products.Contains("Monitor");
int  idx    = products.IndexOf("Monitor");
string? first = products.Find(p => p.StartsWith("M"));

// ── Dictionary<K,V> — key-value, O(1) lookup ────────
var prices = new Dictionary<string, decimal>
{
    ["Laptop"]   = 1299.99m,
    ["Mouse"]    = 29.99m,
    ["Monitor"]  = 449.99m,
};

prices["Keyboard"] = 89.99m;  // add or update

if (prices.TryGetValue("Laptop", out decimal price))
    Console.WriteLine($"Laptop: ${price:F2}");

foreach (var (key, value) in prices)
    Console.WriteLine($"  {key}: ${value:F2}");