C# Generic Collections
Introduction
Generic collections are an essential part of C# programming that allow you to create strongly-typed collections of objects. Introduced in C# 2.0 with the .NET Framework 2.0, generic collections provide type safety, better performance, and cleaner code compared to non-generic collections.
In this tutorial, we'll explore the various generic collections available in C#, how they work, when to use them, and their benefits over non-generic collections.
What are Generic Collections?
Generic collections are a set of classes in the System.Collections.Generic
namespace that provide type-safe data structures. Unlike the older non-generic collections in the System.Collections
namespace, generic collections allow you to specify the exact type of elements they can contain.
The main benefits of using generic collections include:
- Type safety: Compiler checks ensure you can only add appropriate types
- Elimination of boxing/unboxing: Better performance with value types
- Code clarity: Less casting and cleaner code
- Reusability: Same collection class can be used with different types
Common Generic Collections
Let's explore the most commonly used generic collections in C#:
1. List<T>
List<T>
is one of the most commonly used generic collections. It represents a strongly typed list of objects that can be accessed by index.
// Creating a list of integers
List<int> numbers = new List<int>();
// Adding items
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
// Adding multiple items at once
numbers.AddRange(new int[] { 4, 5, 6 });
// Accessing items by index
Console.WriteLine($"First number: {numbers[0]}");
// Iterating through the list
Console.WriteLine("All numbers:");
foreach (int number in numbers)
{
Console.WriteLine(number);
}
// Finding elements
bool containsThree = numbers.Contains(3); // true
int indexOfFive = numbers.IndexOf(5); // 4
// Removing elements
numbers.Remove(3);
numbers.RemoveAt(1); // Remove element at index 1
// Count of elements
Console.WriteLine($"Count: {numbers.Count}");
Output:
First number: 1
All numbers:
1
2
3
4
5
6
Count: 4
2. Dictionary<TKey, TValue>
Dictionary<TKey, TValue>
represents a collection of key-value pairs where each key is unique.
// Creating a dictionary with string keys and int values
Dictionary<string, int> ages = new Dictionary<string, int>();
// Adding key-value pairs
ages.Add("John", 25);
ages.Add("Mary", 30);
ages.Add("Bob", 22);
// Alternative way to add or set values
ages["Alice"] = 28;
// Accessing values
Console.WriteLine($"John's age: {ages["John"]}");
// Checking if a key exists
if (ages.ContainsKey("Bob"))
{
Console.WriteLine($"Bob's age: {ages["Bob"]}");
}
// Safely getting a value
if (ages.TryGetValue("Charlie", out int charlieAge))
{
Console.WriteLine($"Charlie's age: {charlieAge}");
}
else
{
Console.WriteLine("Charlie's age is unknown");
}
// Iterating through a dictionary
Console.WriteLine("\nAll ages:");
foreach (KeyValuePair<string, int> pair in ages)
{
Console.WriteLine($"{pair.Key}: {pair.Value}");
}
// Accessing just keys or values
Console.WriteLine("\nAll names:");
foreach (string name in ages.Keys)
{
Console.WriteLine(name);
}
Output:
John's age: 25
Bob's age: 22
Charlie's age is unknown
All ages:
John: 25
Mary: 30
Bob: 22
Alice: 28
All names:
John
Mary
Bob
Alice
3. HashSet<T>
HashSet<T>
represents a set of unique values. It's useful when you need to ensure you don't have duplicates.
// Creating a HashSet of strings
HashSet<string> fruits = new HashSet<string>();
// Adding items
fruits.Add("Apple");
fruits.Add("Banana");
fruits.Add("Orange");
// Adding duplicate (will be ignored)
bool added = fruits.Add("Apple");
Console.WriteLine($"Was 'Apple' added again? {added}");
// Check if item exists
bool containsBanana = fruits.Contains("Banana"); // true
// Iterating through the set
Console.WriteLine("\nAll fruits:");
foreach (string fruit in fruits)
{
Console.WriteLine(fruit);
}
// Set operations
HashSet<string> moreFruits = new HashSet<string> { "Apple", "Grape", "Kiwi" };
// Union - combines both sets
fruits.UnionWith(moreFruits);
Console.WriteLine("\nAfter union:");
foreach (string fruit in fruits)
{
Console.WriteLine(fruit);
}
// Create new sets for demonstration
HashSet<string> set1 = new HashSet<string> { "A", "B", "C" };
HashSet<string> set2 = new HashSet<string> { "B", "C", "D" };
// Intersection - items that exist in both sets
set1.IntersectWith(set2);
Console.WriteLine("\nIntersection result:");
foreach (string item in set1)
{
Console.WriteLine(item);
}
Output:
Was 'Apple' added again? False
All fruits:
Apple
Banana
Orange
After union:
Apple
Banana
Orange
Grape
Kiwi
Intersection result:
B
C
4. Queue<T>
Queue<T>
represents a first-in, first-out (FIFO) collection of objects.
// Creating a queue of string messages
Queue<string> messageQueue = new Queue<string>();
// Adding items (enqueue)
messageQueue.Enqueue("Message 1");
messageQueue.Enqueue("Message 2");
messageQueue.Enqueue("Message 3");
// Checking what's next without removing (peek)
string nextMessage = messageQueue.Peek();
Console.WriteLine($"Next message to process: {nextMessage}");
// Processing the queue (dequeue)
Console.WriteLine("\nProcessing messages:");
while (messageQueue.Count > 0)
{
string currentMessage = messageQueue.Dequeue();
Console.WriteLine($"Processing: {currentMessage}");
}
// Queue is now empty
Console.WriteLine($"\nMessages remaining: {messageQueue.Count}");
Output:
Next message to process: Message 1
Processing messages:
Processing: Message 1
Processing: Message 2
Processing: Message 3
Messages remaining: 0
5. Stack<T>
Stack<T>
represents a last-in, first-out (LIFO) collection of objects.
// Creating a stack of integers
Stack<int> numberStack = new Stack<int>();
// Adding items (push)
numberStack.Push(10);
numberStack.Push(20);
numberStack.Push(30);
// Checking top item without removing (peek)
int topNumber = numberStack.Peek();
Console.WriteLine($"Top number: {topNumber}");
// Removing and processing items (pop)
Console.WriteLine("\nPopping numbers:");
while (numberStack.Count > 0)
{
int current = numberStack.Pop();
Console.WriteLine($"Popped: {current}");
}
Output:
Top number: 30
Popping numbers:
Popped: 30
Popped: 20
Popped: 10
Real-World Applications
Let's explore some practical scenarios where generic collections are commonly used:
Example 1: Managing a Product Inventory
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public override string ToString()
{
return $"{Id}: {Name} - ${Price} ({StockQuantity} in stock)";
}
}
// Usage example
public void ManageInventory()
{
// Create product inventory using Dictionary with product ID as key
Dictionary<int, Product> inventory = new Dictionary<int, Product>();
// Add products
inventory.Add(1, new Product { Id = 1, Name = "Laptop", Price = 1200.00m, StockQuantity = 10 });
inventory.Add(2, new Product { Id = 2, Name = "Phone", Price = 800.00m, StockQuantity = 15 });
inventory.Add(3, new Product { Id = 3, Name = "Headphones", Price = 150.00m, StockQuantity = 25 });
// Find a product by ID
if (inventory.TryGetValue(2, out Product phone))
{
Console.WriteLine($"Found product: {phone}");
// Update stock
phone.StockQuantity -= 1;
Console.WriteLine($"Updated stock: {phone}");
}
// List all products with less than 15 items in stock
Console.WriteLine("\nLow stock items:");
List<Product> lowStockItems = inventory.Values
.Where(p => p.StockQuantity < 15)
.ToList();
foreach (Product product in lowStockItems)
{
Console.WriteLine(product);
}
// Calculate total inventory value
decimal totalValue = inventory.Values.Sum(p => p.Price * p.StockQuantity);
Console.WriteLine($"\nTotal inventory value: ${totalValue}");
}
Example 2: Task Processing Queue
public class TaskProcessor
{
private readonly Queue<Action> _taskQueue = new Queue<Action>();
public void EnqueueTask(Action task)
{
if (task != null)
{
_taskQueue.Enqueue(task);
Console.WriteLine($"Task added. Queue length: {_taskQueue.Count}");
}
}
public void ProcessTasks()
{
Console.WriteLine($"Starting to process {_taskQueue.Count} tasks...");
while (_taskQueue.Count > 0)
{
Action currentTask = _taskQueue.Dequeue();
Console.WriteLine($"Executing task. Remaining: {_taskQueue.Count}");
try
{
currentTask.Invoke();
}
catch (Exception ex)
{
Console.WriteLine($"Error executing task: {ex.Message}");
}
}
Console.WriteLine("All tasks processed.");
}
}
// Usage example
public void DemonstrateTaskQueue()
{
TaskProcessor processor = new TaskProcessor();
// Add some tasks
processor.EnqueueTask(() => Console.WriteLine("Task 1: Sending email"));
processor.EnqueueTask(() => Console.WriteLine("Task 2: Processing payment"));
processor.EnqueueTask(() => Console.WriteLine("Task 3: Updating database"));
// Process all tasks
processor.ProcessTasks();
}
Choosing the Right Collection
The right generic collection depends on your specific needs:
Collection Type | When to Use |
---|---|
List<T> | When you need an ordered collection that can change size and access elements by index |
Dictionary<TKey, TValue> | When you need fast lookups by unique keys |
HashSet<T> | When you need to ensure uniqueness and perform set operations |
Queue<T> | When you need first-in-first-out (FIFO) behavior |
Stack<T> | When you need last-in-first-out (LIFO) behavior |
SortedList<TKey, TValue> | When you need a sorted dictionary with minimal memory usage |
LinkedList<T> | When you need fast insertions and deletions anywhere in the list |
Performance Considerations
Generic collections offer significant performance advantages over non-generic ones:
- Avoid boxing/unboxing: With value types, generic collections avoid the expensive boxing/unboxing operations.
- Type-specific operations: Operations optimized for the specific type.
- Memory efficiency: Generic collections typically use memory more efficiently.
Here's a simple benchmark example:
using System.Diagnostics;
// Benchmark generic vs. non-generic collections
void RunBenchmark()
{
const int itemCount = 10000000;
// Generic List<int>
Stopwatch genericTimer = Stopwatch.StartNew();
List<int> genericList = new List<int>();
for (int i = 0; i < itemCount; i++)
{
genericList.Add(i);
}
int genericSum = 0;
foreach (int i in genericList)
{
genericSum += i;
}
genericTimer.Stop();
// Non-generic ArrayList
Stopwatch nonGenericTimer = Stopwatch.StartNew();
System.Collections.ArrayList nonGenericList = new System.Collections.ArrayList();
for (int i = 0; i < itemCount; i++)
{
nonGenericList.Add(i);
}
int nonGenericSum = 0;
foreach (object o in nonGenericList)
{
nonGenericSum += (int)o;
}
nonGenericTimer.Stop();
Console.WriteLine($"Generic List<int> time: {genericTimer.ElapsedMilliseconds}ms");
Console.WriteLine($"Non-generic ArrayList time: {nonGenericTimer.ElapsedMilliseconds}ms");
}
Summary
Generic collections in C# provide type-safe, efficient, and flexible ways to store and manipulate data. They offer significant improvements over non-generic collections by providing compile-time type checking, eliminating boxing/unboxing operations, and making code more readable and maintainable.
Key points to remember:
List<T>
for dynamic arraysDictionary<TKey, TValue>
for key-value pairsHashSet<T>
for unique values and set operationsQueue<T>
for first-in, first-out operationsStack<T>
for last-in, first-out operations
When working with collections in modern C# applications, you should almost always choose generic collections over their non-generic counterparts.
Exercises
-
Create a
Dictionary<string, List<string>>
to represent a course catalog where the key is the course name and the value is a list of enrolled student names. -
Implement a simple task scheduler using
Queue<T>
that processes tasks in the order they were added. -
Create a
HashSet<T>
to find and remove duplicate entries from a list of items. -
Implement a simple browser history feature using
Stack<string>
to track visited URLs. -
Create a custom generic class that uses a
List<T>
internally but provides additional methods specific to your application's needs.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)