On this page
Generics and collections 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 neededList\
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")); // ZoeIEnumerable\
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
- Inventory: Create a
Dictionary<string, int>of products and quantities. Add methods to Add, Remove (checking stock), and List only items with stock > 0. - Generic Stack class: Implement
public class Stack<T>withPush,Pop,Peek, andCountusing aList<T>internally. - HashSet deduplication: Given a
List<string>with duplicate email addresses, useHashSet<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}");
Sign in to track your progress