1. A Little Backstory
Back in the day, C# only gave you two joys when working with collections: use arrays or so-called "shallowly typed" collections — like ArrayList, where you could throw in objects of any type. Sounds like freedom, right? But as soon as you mixed a string and an int in that collection — chaos started: you couldn't safely pull out an element without extra (and not always working) type checks. And then — nasty errors out of nowhere and a lot of type casting dance moves.
Example of a non-generic collection (ArrayList):
using System.Collections;
ArrayList stuff = new ArrayList();
stuff.Add(42);
stuff.Add("Hello C#");
int number = (int)stuff[0]; // OK
string text = (string)stuff[1]; // OK
int fail = (int)stuff[1]; // BOOM! InvalidCastException (runtime error)
Yeah, the compiler doesn't complain — the error pops up only at runtime! It's like opening your fridge and finding a hot pot inside — surprise!
What's the point of generics?
Generic collections showed up so you could create collections with a guaranteed type of content. That gives you three key advantages:
- Type safety at compile time. The compiler won't let you accidentally shove something extra into your collection.
- Convenience: no need to cast types manually every time.
- Performance: you don't waste time on unnecessary "boxing and unboxing" of value types (boxing/unboxing).
2. What are Generics anyway?
Generics — it's a magical way to describe data structures and methods so that they work with any type, but still stay strictly typed.
Imagine a universal box you can use for books or socks, but only for one type at a time. If the box is declared "for books only" — nobody can sneak socks in there. Same with generic collections: if you create a collection of int, a string won't accidentally get in.
Example of declaring a generic class
public class Box<T>
{
public T Value { get; set; }
}
var boxOfInt = new Box<int> { Value = 42 };
var boxOfString = new Box<string> { Value = "Hello Generics!" };
T — that's the "type parameter" that tells your box what it's going to work with. C# will always make you clearly specify what type you're putting in.
Generic collections in .NET
In .NET Framework, pretty much all modern collections have generic versions. These are:
- List<T> — a dynamic list of elements of type T.
- Dictionary<TKey, TValue> — an associative array (dictionary) with keys and values.
- Queue<T>, Stack<T> — queues and stacks.
- and many more.
3. How it works: the internals of Generics
Type parameters
When you declare a collection like List<int>, the C# compiler creates a separate variant (specialization) of that class just for the int type. When you declare List<string>, the C# compiler creates another variant of that class, but for strings, and so on.
For you as a programmer, it works like magic:
List<int> numbers = new List<int>();
numbers.Add(1); // You can only add int
List<string> words = new List<string>();
words.Add("hello"); // You can only add string
If you try to add an element of another type, the compiler will instantly get mad:
numbers.Add("fail"); // ERROR at compile time!
Type safety: Compile-time vs. Run-time
That means errors like the one above won't even make it to the program's run-time stage (run-time). Your collection is so protected, nobody can mess with it — the compiler is guarding it like Cerberus at the gates.
Under the hood
- For reference types (like string, object) — no more unnecessary type casts.
- For value types (int, double) — the "boxing/unboxing" problem disappears (boxing/unboxing).
- Using generics in .NET doesn't lead to code bloat — the CLR optimizes it at JIT compilation time.
4. How generics boost performance
Let's highlight again those magical properties that Generics bring to our code:
Type Safety:
This is probably the most important thing. Thanks to Generics, the compiler becomes your personal bodyguard, not letting any "strangers" into your collection. You can be totally sure that List<Product> will only contain Product objects, not some random strings or numbers. This wipes out a whole class of bugs that used to show up only at runtime and cause unexpected "explosions" (InvalidCastException). Your code gets more reliable and predictable.
Performance:
Like we said, for value types (like int, double or DateTime) Generics let you work with memory and CPU time way more efficiently. For collections that store millions of elements or get changed a lot, this can be a critical factor. Instead of constantly moving oranges from a box to a bag and back, you just put them straight into the right box.
Code Reusability:
Generics let you write the same code that works with different data types without having to duplicate it. Say you need a function to swap two variables. Without Generics, you'd have to write SwapInt(ref int a, ref int b), SwapString(ref string a, ref string b), SwapProduct(ref Product a, ref Product b) and so on. With Generics, you write just one function: Swap<T>(ref T a, ref T b). This also applies to collections: you don't need IntList, StringList, ProductList – just List<T> is enough. Your code gets more compact, easy to maintain, and scalable.
5. Generic methods and your own generic classes
Generic methods
Generics aren't just about collections! You can write your own generic methods that work with any type. That makes your code way more universal.
//Specify the type parameter T right after the method name
public static void Swap<T>(ref T x, ref T y)
{
T temp = x;
x = y;
y = temp;
}
// Usage:
int a = 10, b = 20;
Swap(ref a, ref b); // Now a == 20, b == 10
string one = "one", two = "two";
Swap(ref one, ref two); // Works for strings too!
Notice that the C# compiler can figure out the method's type parameter itself based on the types of the variables you pass in. So in the example above, you don't need to pass the type to Swap explicitly.
Your own generic classes
You can even create your own "boxes" (generic classes). It's easier than it looks:
public class Pair<TFirst, TSecond>
{
public TFirst First { get; set; }
public TSecond Second { get; set; }
}
// Usage:
var pair = new Pair<int, string> { First = 42, Second = "answer" };
6. Example of using generic collections
Let's get back to our "to-do list" that we started building in examples from previous lectures. Before, we stored tasks in an array or just printed them to the screen. Now — let's store them dynamically in a List<string>.
Example: dynamically adding tasks to a list
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<string> tasks = new List<string>();
Console.WriteLine("Enter a task (or an empty line to finish):");
string input;
while (!string.IsNullOrWhiteSpace(input = Console.ReadLine()))
{
tasks.Add(input);
Console.WriteLine("Task added! Enter another (or an empty line to finish):");
}
Console.WriteLine("\nYour to-do list for today:");
foreach (string task in tasks)
{
Console.WriteLine("- " + task);
}
}
}
What's good here?
- The collection automatically grows as you add new tasks.
- You can't accidentally add anything but a string to the list.
- You can easily loop through the list and print its contents.
7. Typical beginner mistakes, features, and tips
Switching from non-generic collections to generics can be confusing. Here are a couple of common situations newbies run into:
- Trying to add an element of the wrong type (List<int> numbers = new List<int>(); numbers.Add("hi"); // Error).
- Wanting to mix types in one collection — here you need to pick a base type (like List<object>) — and then you'll always have to cast back to the needed type.
- Forgetting about constraints, writing too "wide" generics and getting weird compile-time errors.
GO TO FULL VERSION