Collections and Generics
🎯 What You'll Learn
- What collections are and why they're essential
- The limitations of arrays and why we need better solutions
- Understanding generics and type parameters
- Working with
List<T>for dynamic lists - Using
Dictionary<TKey, TValue>for key-value pairs - Other useful collections:
HashSet<T>,Queue<T>,Stack<T> - Collection initialization syntax
- Iterating through collections with foreach
- Choosing the right collection for your needs
- Building InvenTrack's product catalog and customer database
The Problem: Working with Multiple Objects
So far, we've worked with individual objects—a single product, one customer, one order. But real applications deal with many objects: hundreds of products, thousands of customers, millions of transactions. How do we manage groups of related objects efficiently?
A collection is a data structure that holds multiple objects of the same type. Collections provide methods to add, remove, search, and iterate through items. They're the foundation of working with data in any real-world application.
Arrays: The Basic Collection
You've already seen arrays—they're the simplest collection. But they have significant limitations.
// Arrays have fixed size
string[] products = new string[3];
products[0] = "Laptop";
products[1] = "Mouse";
products[2] = "Keyboard";
// What if we need to add a 4th product? We can't!
// products[3] = "Monitor"; // ERROR: Index out of bounds
// We'd have to create a new, larger array and copy everything
string[] newProducts = new string[4];
for (int i = 0; i < products.Length; i++)
{
newProducts[i] = products[i];
}
newProducts[3] = "Monitor";
products = newProducts; // Tedious!
Fixed size: Can't grow or shrink after creation
No built-in methods: No easy way to add, remove, or search
Manual management: You handle all the complexity yourself
Enter Generics: Type-Safe, Reusable Code
Before we dive into collections, we need to understand generics—one of C#'s most powerful features.
Generics let you write code that works with any type, while maintaining type safety. Instead of writing separate code for a list of strings, a list of integers, and a list of products, you write one generic list that works with all types.
List<T> is like saying "a list of ___" where you fill in the blank:
List<string> (a list of strings), List<int> (a list
of integers), List<Product> (a list of products). Same list behavior,
different types.
Generic Type Parameters
// T is a type parameter - a placeholder for any type
List<string> names; // T = string
List<int> numbers; // T = int
List<Product> products; // T = Product
Dictionary<string, Customer> customers; // TKey = string, TValue = Customer
List<T>: The Workhorse Collection
List<T> is the most commonly used collection. It's a dynamic array
that grows automatically as you add items.
Creating and Using Lists
using System.Collections.Generic;
// Create an empty list
List<string> productNames = new List<string>();
// Add items
productNames.Add("Laptop");
productNames.Add("Mouse");
productNames.Add("Keyboard");
productNames.Add("Monitor"); // No problem! List grows automatically
// Access by index (like arrays)
Console.WriteLine(productNames[0]); // Laptop
// Check count
Console.WriteLine($"We have {productNames.Count} products"); // 4
// Remove items
productNames.Remove("Mouse"); // Removes first occurrence
productNames.RemoveAt(0); // Removes by index
// Check if item exists
if (productNames.Contains("Keyboard"))
{
Console.WriteLine("We have keyboards in stock");
}
// Clear all items
productNames.Clear();
Collection Initializer Syntax
// Initialize with values
List<string> categories = new List<string>
{
"Electronics",
"Furniture",
"Clothing",
"Books"
};
// Even shorter with C# 9+ (target-typed new)
List<int> quantities = new() { 10, 25, 5, 100 };
// Initialize list of objects
List<Product> products = new()
{
new Product("LAP-001", "Dell XPS", 1299.99m, 10),
new Product("MOU-001", "Logitech MX", 99.99m, 50),
new Product("KEY-001", "Mechanical KB", 149.99m, 30)
};
Common List Operations
List<int> numbers = new() { 5, 2, 8, 1, 9, 3 };
// Sort
numbers.Sort(); // { 1, 2, 3, 5, 8, 9 }
// Reverse
numbers.Reverse(); // { 9, 8, 5, 3, 2, 1 }
// Find index of item
int index = numbers.IndexOf(5); // 2
// Insert at specific position
numbers.Insert(0, 100); // Insert 100 at beginning
// Get a range
List<int> subset = numbers.GetRange(0, 3); // First 3 items
// Convert to array
int[] array = numbers.ToArray();
Iterating Through Lists
List<Product> products = GetProducts();
// foreach - most common
foreach (Product product in products)
{
Console.WriteLine($"{product.Name}: {product.Price:C}");
}
// for loop - when you need the index
for (int i = 0; i < products.Count; i++)
{
Console.WriteLine($"#{{i + 1}: {products[i].Name}");
}
// ForEach method with lambda
products.ForEach(p => Console.WriteLine(p.Name));
Dictionary<TKey, TValue>: Key-Value Pairs
A Dictionary stores pairs of keys and values. It's like a real dictionary
where you look up a word (key) to find its definition (value). Dictionaries provide
extremely fast lookups by key.
// Dictionary of product SKU to Product object
Dictionary<string, Product> productCatalog = new();
// Add items
productCatalog.Add("LAP-001", new Product("LAP-001", "Dell XPS", 1299.99m, 10));
productCatalog.Add("MOU-001", new Product("MOU-001", "Logitech MX", 99.99m, 50));
// Alternative: indexer syntax
productCatalog["KEY-001"] = new Product("KEY-001", "Mechanical KB", 149.99m, 30);
// Retrieve by key - FAST!
Product laptop = productCatalog["LAP-001"];
Console.WriteLine(laptop.Name);
// Check if key exists
if (productCatalog.ContainsKey("MOU-001"))
{
Console.WriteLine("Mouse is in catalog");
}
// Safe retrieval with TryGetValue
if (productCatalog.TryGetValue("MON-001", out Product monitor))
{
Console.WriteLine($"Found: {monitor.Name}");
}
else
{
Console.WriteLine("Monitor not found");
}
// Remove by key
productCatalog.Remove("KEY-001");
// Count
Console.WriteLine($"Catalog has {productCatalog.Count} products");
Dictionary Initialization
// Initialize with values
Dictionary<string, decimal> prices = new()
{
{ "Laptop", 1299.99m },
{ "Mouse", 99.99m },
{ "Keyboard", 149.99m }
};
// Alternative syntax (C# 6+)
Dictionary<int, string> customerNames = new()
{
[1001] = "Akwasi Asante",
[1002] = "Jane Smith",
[1003] = "Bob Johnson"
};
Iterating Through Dictionaries
Dictionary<string, Product> catalog = GetCatalog();
// Iterate through key-value pairs
foreach (KeyValuePair<string, Product> entry in catalog)
{
Console.WriteLine($"SKU: {entry.Key}, Name: {entry.Value.Name}");
}
// Shorter with var
foreach (var entry in catalog)
{
Console.WriteLine($"{entry.Key}: {entry.Value.Price:C}");
}
// Iterate through keys only
foreach (string sku in catalog.Keys)
{
Console.WriteLine(sku);
}
// Iterate through values only
foreach (Product product in catalog.Values)
{
Console.WriteLine(product.Name);
}
Use a Dictionary when you need to look up items by a unique key. Perfect for:
product catalogs (by SKU), customer databases (by ID), configuration settings (by name),
caching (by cache key). Lookups are O(1) - nearly instant regardless of size!
Other Useful Collections
HashSet<T>: Unique Items Only
A HashSet is like a List, but it automatically prevents duplicates
and provides very fast lookups.
HashSet<string> categories = new();
categories.Add("Electronics");
categories.Add("Furniture");
categories.Add("Electronics"); // Duplicate - won't be added
Console.WriteLine(categories.Count); // 2 (not 3!)
// Fast Contains check
if (categories.Contains("Electronics"))
{
Console.WriteLine("We have electronics");
}
// Set operations
HashSet<string> set1 = new() { "A", "B", "C" };
HashSet<string> set2 = new() { "B", "C", "D" };
set1.UnionWith(set2); // { A, B, C, D }
set1.IntersectWith(set2); // { B, C }
set1.ExceptWith(set2); // { A }
Queue<T>: First-In, First-Out (FIFO)
// Process orders in the order they arrive
Queue<string> orderQueue = new();
// Add to end of queue
orderQueue.Enqueue("Order #1");
orderQueue.Enqueue("Order #2");
orderQueue.Enqueue("Order #3");
// Remove from front of queue
string nextOrder = orderQueue.Dequeue(); // "Order #1"
// Peek at front without removing
string peek = orderQueue.Peek(); // "Order #2" (still in queue)
Console.WriteLine(orderQueue.Count); // 2
Stack<T>: Last-In, First-Out (LIFO)
// Undo/Redo functionality
Stack<string> undoStack = new();
// Add to top of stack
undoStack.Push("Action 1");
undoStack.Push("Action 2");
undoStack.Push("Action 3");
// Remove from top (most recent)
string lastAction = undoStack.Pop(); // "Action 3"
// Peek at top without removing
string peek = undoStack.Peek(); // "Action 2"
Building InvenTrack: Product Catalog and Customer Database
Let's apply what we've learned to build a real product catalog and customer management system for InvenTrack.
class ProductCatalog
{
private Dictionary<string, Product> products;
private HashSet<string> categories;
public ProductCatalog()
{
products = new Dictionary<string, Product>();
categories = new HashSet<string>();
}
public void AddProduct(Product product)
{
if (products.ContainsKey(product.Sku))
{
Console.WriteLine($"Product {product.Sku} already exists");
return;
}
products[product.Sku] = product;
Console.WriteLine($"Added {product.Name} to catalog");
}
public Product GetProduct(string sku)
{
if (products.TryGetValue(sku, out Product product))
{
return product;
}
return null;
}
public List<Product> GetAllProducts()
{
return products.Values.ToList();
}
public List<Product> GetLowStockProducts()
{
List<Product> lowStock = new();
foreach (Product product in products.Values)
{
if (product.IsLowStock)
{
lowStock.Add(product);
}
}
return lowStock;
}
public decimal GetTotalInventoryValue()
{
decimal total = 0m;
foreach (Product product in products.Values)
{
total += product.GetInventoryValue();
}
return total;
}
public void DisplayCatalog()
{
Console.WriteLine("\n=== Product Catalog ===");
Console.WriteLine($"Total Products: {products.Count}");
Console.WriteLine($"Total Value: {GetTotalInventoryValue():C}\n");
foreach (var entry in products)
{
Product p = entry.Value;
string status = p.IsLowStock ? "[LOW STOCK]" : "";
Console.WriteLine($"{p.Sku}: {p.Name} - {p.Price:C} (Qty: {p.QuantityInStock}) {status}");
}
}
}
class CustomerDatabase
{
private Dictionary<int, Customer> customers;
private Dictionary<string, int> emailToId;
public CustomerDatabase()
{
customers = new Dictionary<int, Customer>();
emailToId = new Dictionary<string, int>();
}
public void AddCustomer(Customer customer)
{
customers[customer.Id] = customer;
emailToId[customer.Email] = customer.Id;
}
public Customer GetCustomerById(int id)
{
return customers.TryGetValue(id, out Customer customer) ? customer : null;
}
public Customer GetCustomerByEmail(string email)
{
if (emailToId.TryGetValue(email, out int id))
{
return customers[id];
}
return null;
}
public List<Customer> GetAllCustomers()
{
return customers.Values.ToList();
}
}
// Usage
ProductCatalog catalog = new();
catalog.AddProduct(new Product("LAP-001", "Dell XPS 15", 1299.99m, 10));
catalog.AddProduct(new Product("MOU-001", "Logitech MX", 99.99m, 5));
catalog.AddProduct(new Product("KEY-001", "Mechanical KB", 149.99m, 30));
catalog.DisplayCatalog();
List<Product> lowStock = catalog.GetLowStockProducts();
Console.WriteLine($"\nLow stock items: {lowStock.Count}");
Notice how we use Dictionary for fast lookups by SKU and ID, and
List when we need to return multiple items. The emailToId
dictionary is a secondary index—a common pattern for looking up the same data by
different keys!
Choosing the Right Collection
| Collection | When to Use | Key Features |
|---|---|---|
List<T> |
Ordered list of items, need index access | Dynamic size, fast index access, maintains order |
Dictionary<TKey, TValue> |
Look up values by unique key | Extremely fast lookups, key-value pairs |
HashSet<T> |
Unique items only, fast membership tests | No duplicates, fast Contains(), set operations |
Queue<T> |
Process items in order received (FIFO) | Enqueue/Dequeue, first-in-first-out |
Stack<T> |
Process most recent first (LIFO) | Push/Pop, last-in-first-out |
Array T[] |
Fixed size, performance-critical scenarios | Fixed size, fastest access, lowest memory |
List: Add/Remove at end = O(1), Insert/Remove middle = O(n)
Dictionary: Add/Remove/Lookup = O(1) average
HashSet: Add/Remove/Contains = O(1) average
Queue/Stack: Enqueue/Dequeue/Push/Pop = O(1)
Key Takeaways
- Collections store multiple objects of the same type
- Generics (
<T>) provide type-safe, reusable code - List<T> is the go-to collection for ordered, dynamic lists
- Dictionary<TKey, TValue> provides lightning-fast lookups by key
- HashSet<T> ensures uniqueness and fast membership tests
- Queue<T> and Stack<T> for specialized ordering needs
- Use collection initializers for cleaner, more readable code
- foreach is the preferred way to iterate through collections
- Choose collections based on your access patterns and performance needs
- Real applications combine multiple collection types for different purposes
You now know how to work with collections of objects! In the next section, we'll explore LINQ (Language Integrated Query)—a powerful feature that lets you query, filter, transform, and aggregate collections with elegant, SQL-like syntax. LINQ will revolutionize how you work with data in C#!