6.2 - Introduction to Collections
While arrays are useful for storing fixed-size collections of data, modern C# programming often requires more flexible and feature-rich data structures. This is where the System.Collections.Generic
namespace comes in, providing a variety of collection types that can dynamically resize and offer specialized operations for different scenarios.
Why Use Collections Instead of Arrays?
Arrays are great for many purposes, but they have several limitations:
- Fixed Size: Once an array is created, its size cannot be changed.
- Limited Functionality: Arrays have minimal built-in methods for common operations like adding, removing, or finding elements.
- Inflexible: All operations that change the array's structure (like inserting or removing elements) require manual element shifting.
Collections address these limitations by providing:
- Dynamic Sizing: Most collections can grow or shrink as needed.
- Rich Functionality: Collections include methods for adding, removing, searching, and sorting elements.
- Specialized Behaviors: Different collection types are optimized for different usage patterns (e.g., fast lookups, first-in-first-out access).
- Type Safety: Generic collections ensure that only elements of the correct type can be added.
Understanding Generics in C#
Before diving into collections, it's important to understand generics, a feature introduced in C# 2.0 that enables you to define type-safe data structures without committing to specific data types.
What Are Generics?
Generics allow you to define classes, interfaces, and methods with placeholder types that are specified when the code is used, rather than when it's written. This provides several benefits:
- Type Safety: Compile-time type checking prevents type errors.
- Code Reuse: Write the collection logic once and use it with any type.
- Performance: Avoids the boxing and unboxing operations required by non-generic collections.
Generic Syntax
Generic types are denoted with angle brackets (<>
) containing type parameters:
// Generic class declaration
public class Container<T>
{
private T item;
public Container(T item)
{
this.item = item;
}
public T GetItem()
{
return item;
}
}
// Using the generic class with different types
Container<int> intContainer = new Container<int>(42);
int value = intContainer.GetItem(); // 42
Container<string> stringContainer = new Container<string>("Hello");
string text = stringContainer.GetItem(); // "Hello"
Type Constraints
You can restrict the types that can be used with a generic by applying constraints:
// T must be a class (reference type)
public class ReferenceContainer<T> where T : class
{
// Implementation
}
// T must be a struct (value type)
public class ValueContainer<T> where T : struct
{
// Implementation
}
// T must have a parameterless constructor
public class ConstructibleContainer<T> where T : new()
{
// Implementation
}
// T must implement an interface
public class ComparableContainer<T> where T : IComparable<T>
{
// Implementation
}
// Multiple constraints
public class AdvancedContainer<T> where T : class, IComparable<T>, new()
{
// Implementation
}
The System.Collections.Generic Namespace
The System.Collections.Generic
namespace contains the most commonly used collection types in C#. These collections are generic, meaning they can work with any data type while maintaining type safety.
Key Generic Collections
Here's an overview of the most important generic collections:
List<T>
: A dynamic array that can grow or shrink as needed.Dictionary<TKey, TValue>
: A collection of key-value pairs for fast lookups.Queue<T>
: A first-in, first-out (FIFO) collection.Stack<T>
: A last-in, first-out (LIFO) collection.HashSet<T>
: A collection of unique elements with fast lookups.LinkedList<T>
: A doubly-linked list for efficient insertions and removals.SortedList<TKey, TValue>
: A sorted list of key-value pairs.SortedSet<T>
: A sorted set of unique elements.SortedDictionary<TKey, TValue>
: A sorted dictionary of key-value pairs.
We'll explore the most commonly used collections in detail in the following sections.
Legacy Non-Generic Collections
Before generics were introduced in C# 2.0, the System.Collections
namespace provided non-generic collections like ArrayList
, Hashtable
, and Queue
. These collections store elements as object
types, which means:
- They can hold any type of object, but at the cost of type safety.
- When storing value types (like
int
orfloat
), they require boxing and unboxing operations, which can impact performance.
// Non-generic ArrayList (legacy)
ArrayList legacyList = new ArrayList();
legacyList.Add(42); // Boxing: int -> object
legacyList.Add("Hello"); // String is already a reference type
legacyList.Add(new Person()); // Adding a custom class
// Need to cast when retrieving elements
int number = (int)legacyList[0]; // Unboxing: object -> int
string text = (string)legacyList[1];
// Type safety issues - this compiles but throws an exception at runtime
// int anotherNumber = (int)legacyList[1]; // InvalidCastException
While these legacy collections are still available, you should generally avoid them in new code. Use the generic collections from System.Collections.Generic
instead for better type safety and performance.
Collections in Game Development
Collections are extensively used in game development for various purposes:
Common Use Cases
-
Inventory Systems: Storing and managing player items.
List<Item> inventory = new List<Item>();
Dictionary<ItemType, List<Item>> categorizedInventory = new Dictionary<ItemType, List<Item>>(); -
Object Pooling: Reusing game objects to improve performance.
Queue<GameObject> bulletPool = new Queue<GameObject>();
Dictionary<string, Queue<GameObject>> objectPools = new Dictionary<string, Queue<GameObject>>(); -
Game State Management: Tracking active entities and their states.
HashSet<Enemy> activeEnemies = new HashSet<Enemy>();
Dictionary<int, Player> connectedPlayers = new Dictionary<int, Player>(); -
Path Finding: Managing open and closed nodes in pathfinding algorithms.
List<Node> path = new List<Node>();
HashSet<Node> visitedNodes = new HashSet<Node>(); -
Command Systems: Implementing undo/redo functionality.
Stack<ICommand> undoStack = new Stack<ICommand>();
Stack<ICommand> redoStack = new Stack<ICommand>(); -
Event Systems: Managing event subscribers.
Dictionary<EventType, List<Action>> eventSubscribers = new Dictionary<EventType, List<Action>>();
Unity-Specific Considerations
When using collections in Unity, there are a few important considerations:
-
Serialization: Unity's serialization system has limitations with generic collections. To make collections visible in the Inspector, you may need to:
- Use arrays instead for simple cases
- Create custom property drawers
- Use the
[SerializeField]
attribute with specialized serializable collection types
-
Performance: In performance-critical code (like
Update
methods that run every frame), be mindful of:- Garbage collection caused by frequent collection modifications
- The cost of LINQ operations on collections
- The overhead of foreach loops compared to for loops with direct indexing
-
Thread Safety: Unity's main thread restrictions mean that collections accessed from multiple threads (like coroutines and jobs) need proper synchronization.
Unity's internal systems make extensive use of collections. For example, Unity uses collections to manage:
- GameObjects in a scene
- Components attached to GameObjects
- Physics contacts and triggers
- Particle system elements
- Animation keyframes
- UI elements
Understanding how to effectively use collections will help you work with Unity's APIs and implement your own game systems efficiently.
Choosing Between Arrays and Collections
Here's a quick guide to help you decide when to use arrays versus collections:
Use Arrays When:
- You need a fixed-size collection that won't change
- Maximum performance is critical
- You want direct serialization in Unity's Inspector
- You're working with simple, primitive data types in a straightforward way
Use Collections When:
- You need a dynamically sized collection
- You need specialized operations (like fast lookups or FIFO/LIFO behavior)
- You're implementing complex data structures
- You want cleaner, more maintainable code with built-in functionality
Practical Example: Player Abilities System
Let's look at a practical example of using collections in a game context - a system for managing player abilities:
using System;
using System.Collections.Generic;
// Ability class representing a player skill or power
public class Ability
{
public string Name { get; private set; }
public float Cooldown { get; private set; }
public float RemainingCooldown { get; set; }
public bool IsReady => RemainingCooldown <= 0;
public Ability(string name, float cooldown)
{
Name = name;
Cooldown = cooldown;
RemainingCooldown = 0;
}
public virtual void Use()
{
if (IsReady)
{
Console.WriteLine($"Using ability: {Name}");
RemainingCooldown = Cooldown;
}
else
{
Console.WriteLine($"{Name} is on cooldown: {RemainingCooldown:F1}s remaining");
}
}
public void UpdateCooldown(float deltaTime)
{
if (RemainingCooldown > 0)
{
RemainingCooldown = Math.Max(0, RemainingCooldown - deltaTime);
}
}
}
// Player class that manages multiple abilities
public class Player
{
// List to store all available abilities
private List<Ability> abilities = new List<Ability>();
// Dictionary for quick ability lookup by name
private Dictionary<string, Ability> abilityLookup = new Dictionary<string, Ability>();
// Queue for ability combo system
private Queue<string> abilityComboQueue = new Queue<string>();
// Recently used abilities (for combo detection)
private Stack<Ability> recentlyUsed = new Stack<Ability>();
public Player()
{
// Initialize with some default abilities
AddAbility(new Ability("Fireball", 3.0f));
AddAbility(new Ability("Heal", 5.0f));
AddAbility(new Ability("Shield", 8.0f));
AddAbility(new Ability("Teleport", 10.0f));
}
public void AddAbility(Ability ability)
{
abilities.Add(ability);
abilityLookup[ability.Name] = ability;
Console.WriteLine($"Added ability: {ability.Name}");
}
public void UseAbility(string abilityName)
{
if (abilityLookup.TryGetValue(abilityName, out Ability ability))
{
ability.Use();
if (ability.IsReady)
{
// Track recently used abilities for combo detection
recentlyUsed.Push(ability);
// Add to combo queue
abilityComboQueue.Enqueue(abilityName);
if (abilityComboQueue.Count > 3)
{
abilityComboQueue.Dequeue(); // Keep only the 3 most recent abilities
}
// Check for combos
CheckForCombos();
}
}
else
{
Console.WriteLine($"Ability not found: {abilityName}");
}
}
public void Update(float deltaTime)
{
// Update cooldowns for all abilities
foreach (Ability ability in abilities)
{
ability.UpdateCooldown(deltaTime);
}
}
private void CheckForCombos()
{
// Convert queue to array for easier combo checking
string[] combo = abilityComboQueue.ToArray();
// Check for specific 3-ability combos
if (combo.Length == 3)
{
if (combo[0] == "Fireball" && combo[1] == "Fireball" && combo[2] == "Teleport")
{
Console.WriteLine("COMBO: Fire Blink - Teleport with a fiery explosion!");
}
else if (combo[0] == "Shield" && combo[1] == "Heal" && combo[2] == "Shield")
{
Console.WriteLine("COMBO: Fortification - Double shield strength with healing aura!");
}
}
}
public void DisplayAbilities()
{
Console.WriteLine("Available Abilities:");
foreach (Ability ability in abilities)
{
string status = ability.IsReady ? "Ready" : $"Cooldown: {ability.RemainingCooldown:F1}s";
Console.WriteLine($"- {ability.Name}: {status}");
}
}
}
// Example usage
public class Program
{
public static void Main()
{
Player player = new Player();
// Display initial abilities
player.DisplayAbilities();
// Use some abilities
player.UseAbility("Fireball");
player.UseAbility("Fireball");
player.UseAbility("Teleport"); // This should trigger a combo
// Simulate time passing
Console.WriteLine("\nAfter 2 seconds:");
player.Update(2.0f);
player.DisplayAbilities();
// Try using an ability on cooldown
player.UseAbility("Fireball");
// Simulate more time passing
Console.WriteLine("\nAfter 5 more seconds:");
player.Update(5.0f);
player.DisplayAbilities();
}
}
This example demonstrates several collection types working together:
List<Ability>
stores all player abilitiesDictionary<string, Ability>
provides fast lookup by ability nameQueue<string>
tracks recent ability usage for combo detectionStack<Ability>
keeps track of the order abilities were used
Each collection type is chosen for its specific strengths:
- Lists for general storage and iteration
- Dictionaries for fast lookups by name
- Queues for tracking sequences in order (first in, first out)
- Stacks for tracking recent actions (last in, first out)
Conclusion
Collections in C# provide powerful, flexible alternatives to arrays for managing groups of data. By understanding the different collection types available and their specific strengths, you can choose the right tool for each task in your game development projects.
In the following sections, we'll explore each of the main collection types in detail, starting with the versatile List<T>
class.