6.3 - List<T>
The List<T>
class is one of the most commonly used collection types in C#. It represents a strongly typed list of objects that can be accessed by index, similar to an array, but with the ability to dynamically resize as elements are added or removed.
Why Use List<T>?
List<T>
combines the best features of arrays with additional flexibility:
- Dynamic Sizing: Unlike arrays, lists can grow or shrink as needed.
- Rich API: Lists provide numerous methods for adding, removing, searching, and sorting elements.
- Type Safety: The generic parameter
T
ensures that only elements of the specified type can be added. - Familiar Indexing: Like arrays, lists support direct indexing with the
[]
operator. - Efficient Operations: Lists are optimized for fast access and manipulation of elements.
Creating and Initializing Lists
There are several ways to create and initialize a List<T>
:
Basic Creation
// Create an empty list of integers
List<int> scores = new List<int>();
// Create an empty list of strings
List<string> playerNames = new List<string>();
// Create a list with an initial capacity
List<float> distances = new List<float>(100);
Setting an initial capacity can improve performance when you know approximately how many elements you'll add, as it reduces the number of internal array resizes.
Initializing with Elements
// Initialize with collection initializer syntax
List<int> scores = new List<int> { 100, 85, 92, 75, 96 };
// Initialize from an existing array
string[] weaponArray = { "Sword", "Bow", "Axe", "Staff", "Dagger" };
List<string> weapons = new List<string>(weaponArray);
// Initialize from another collection
HashSet<int> uniqueIds = new HashSet<int> { 1, 2, 3, 4, 5 };
List<int> idList = new List<int>(uniqueIds);
Adding Elements to a List
List<T>
provides several methods for adding elements:
List<string> inventory = new List<string>();
// Add a single item to the end
inventory.Add("Health Potion");
// Add multiple items from an array or collection
string[] newItems = { "Mana Potion", "Antidote", "Elixir" };
inventory.AddRange(newItems);
// Insert an item at a specific position
inventory.Insert(1, "Magic Scroll"); // Insert at index 1
// Insert multiple items at a specific position
List<string> specialItems = new List<string> { "Legendary Sword", "Ancient Amulet" };
inventory.InsertRange(2, specialItems);
Accessing List Elements
You can access list elements in several ways:
List<string> inventory = new List<string> { "Health Potion", "Magic Scroll", "Legendary Sword", "Ancient Amulet", "Mana Potion", "Antidote", "Elixir" };
// Access by index (just like arrays)
string firstItem = inventory[0]; // "Health Potion"
string thirdItem = inventory[2]; // "Legendary Sword"
// Modify an element
inventory[1] = "Advanced Magic Scroll";
// Get the first element
string first = inventory.First(); // "Health Potion"
// Get the first element matching a condition
string firstPotion = inventory.First(item => item.Contains("Potion")); // "Health Potion"
// Get the last element
string last = inventory.Last(); // "Elixir"
// Get a single element matching a condition
string amulet = inventory.Single(item => item.Contains("Amulet")); // "Ancient Amulet"
// Try to get an element (returns default if not found)
string maybeScroll = inventory.FirstOrDefault(item => item.Contains("Scroll")); // "Advanced Magic Scroll"
string maybeShield = inventory.FirstOrDefault(item => item.Contains("Shield")); // null
Accessing an index that's out of range will throw an ArgumentOutOfRangeException
. Always check that your index is valid or use methods like FirstOrDefault
that handle missing elements gracefully.
Removing Elements from a List
List<T>
offers multiple ways to remove elements:
List<string> inventory = new List<string> { "Health Potion", "Magic Scroll", "Legendary Sword", "Ancient Amulet", "Mana Potion", "Antidote", "Elixir" };
// Remove a specific element
bool removed = inventory.Remove("Antidote"); // Returns true if found and removed
// Remove element at a specific index
inventory.RemoveAt(1); // Removes "Magic Scroll"
// Remove a range of elements
inventory.RemoveRange(2, 2); // Removes 2 elements starting at index 2
// Remove all elements matching a condition
int potionCount = inventory.RemoveAll(item => item.Contains("Potion"));
Console.WriteLine($"Removed {potionCount} potions");
// Clear the entire list
inventory.Clear();
Searching and Checking Lists
List<T>
provides methods for finding elements and checking conditions:
List<string> inventory = new List<string> { "Health Potion", "Magic Scroll", "Legendary Sword", "Ancient Amulet", "Mana Potion", "Antidote", "Elixir" };
// Check if an element exists
bool hasAntidote = inventory.Contains("Antidote"); // true
bool hasShield = inventory.Contains("Shield"); // false
// Find the index of an element
int swordIndex = inventory.IndexOf("Legendary Sword"); // 2
int shieldIndex = inventory.IndexOf("Shield"); // -1 (not found)
// Find the last index of an element (for duplicates)
inventory.Add("Health Potion"); // Add a duplicate
int lastPotionIndex = inventory.LastIndexOf("Health Potion"); // 7
// Find all indices of "Health Potion" (no built-in method, but we can write one)
List<int> allPotionIndices = new List<int>();
for (int i = 0; i < inventory.Count; i++)
{
if (inventory[i] == "Health Potion")
{
allPotionIndices.Add(i);
}
}
// allPotionIndices contains 0 and 7
// Check if any element matches a condition
bool hasLegendaryItem = inventory.Any(item => item.Contains("Legendary")); // true
// Check if all elements match a condition
bool allItemsNamed = inventory.All(item => item.Length > 0); // true
bool allPotions = inventory.All(item => item.Contains("Potion")); // false
// Find the first element matching a condition
string firstMagicItem = inventory.Find(item => item.Contains("Magic")); // "Magic Scroll"
// Find all elements matching a condition
List<string> allPotions = inventory.FindAll(item => item.Contains("Potion"));
// allPotions contains "Health Potion", "Mana Potion", "Health Potion"
Sorting and Transforming Lists
List<T>
includes methods for sorting and transforming elements:
List<string> inventory = new List<string> { "Health Potion", "Magic Scroll", "Legendary Sword", "Ancient Amulet", "Mana Potion", "Antidote", "Elixir" };
// Sort the list (modifies the original list)
inventory.Sort();
// inventory is now: "Ancient Amulet", "Antidote", "Elixir", "Health Potion", "Legendary Sword", "Magic Scroll", "Mana Potion"
// Sort with a custom comparison
inventory.Sort((a, b) => a.Length.CompareTo(b.Length));
// inventory is now sorted by length
// Reverse the list
inventory.Reverse();
// inventory is now in reverse order
// Convert all elements (modifies the original list)
inventory.ConvertAll(item => item.ToUpper());
// All items are now uppercase
// Create a new list with transformed elements
List<int> itemNameLengths = inventory.ConvertAll(item => item.Length);
// itemNameLengths contains the length of each item name
List Properties and Capacity Management
List<T>
maintains an internal array that may be larger than the number of elements it contains. Understanding capacity management can help optimize performance:
List<int> numbers = new List<int>();
// Add elements
for (int i = 0; i < 10; i++)
{
numbers.Add(i);
}
// Count property - number of elements in the list
Console.WriteLine($"Count: {numbers.Count}"); // 10
// Capacity property - size of the internal array
Console.WriteLine($"Capacity: {numbers.Capacity}"); // Likely 16 (implementation detail)
// Trim excess capacity
numbers.TrimExcess();
Console.WriteLine($"Capacity after trim: {numbers.Capacity}"); // Now closer to Count
// Set capacity explicitly
numbers.Capacity = 100; // Prepare for adding many more elements
Console.WriteLine($"New capacity: {numbers.Capacity}"); // 100
If you know you'll be adding many elements to a list, setting the capacity in advance can improve performance by avoiding multiple resizing operations.
List<T> in Game Development
List<T>
is extensively used in game development for various purposes:
Example: Enemy Spawner
public class EnemySpawner : MonoBehaviour
{
public GameObject[] enemyPrefabs;
public Transform[] spawnPoints;
public int maxEnemies = 10;
private List<GameObject> activeEnemies = new List<GameObject>();
void Start()
{
// Start spawning enemies
StartCoroutine(SpawnEnemies());
}
IEnumerator SpawnEnemies()
{
while (true)
{
// Only spawn if we haven't reached the maximum
if (activeEnemies.Count < maxEnemies)
{
// Choose a random enemy prefab and spawn point
GameObject prefab = enemyPrefabs[Random.Range(0, enemyPrefabs.Length)];
Transform spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)];
// Spawn the enemy
GameObject enemy = Instantiate(prefab, spawnPoint.position, spawnPoint.rotation);
// Add to our active enemies list
activeEnemies.Add(enemy);
// Set up a callback for when the enemy is destroyed
Enemy enemyComponent = enemy.GetComponent<Enemy>();
if (enemyComponent != null)
{
enemyComponent.OnDestroyed += () => RemoveEnemy(enemy);
}
}
// Wait before spawning the next enemy
yield return new WaitForSeconds(Random.Range(1f, 5f));
}
}
void RemoveEnemy(GameObject enemy)
{
// Remove from our active enemies list
activeEnemies.Remove(enemy);
}
void Update()
{
// Clean up any null references (enemies destroyed without calling our callback)
activeEnemies.RemoveAll(enemy => enemy == null);
// Display the current enemy count
Debug.Log($"Active enemies: {activeEnemies.Count}/{maxEnemies}");
}
}
Example: Inventory System
[System.Serializable]
public class InventoryItem
{
public string Name;
public string Description;
public Sprite Icon;
public int Quantity;
public InventoryItem(string name, string description, Sprite icon, int quantity = 1)
{
Name = name;
Description = description;
Icon = icon;
Quantity = quantity;
}
}
public class InventoryManager : MonoBehaviour
{
[SerializeField] private int maxInventorySize = 20;
// The main inventory list
private List<InventoryItem> inventory = new List<InventoryItem>();
// Event for UI updates
public event Action OnInventoryChanged;
// Add an item to the inventory
public bool AddItem(InventoryItem newItem)
{
// Check if we already have this item
InventoryItem existingItem = inventory.Find(item => item.Name == newItem.Name);
if (existingItem != null)
{
// We already have this item, just increase the quantity
existingItem.Quantity += newItem.Quantity;
OnInventoryChanged?.Invoke();
return true;
}
else if (inventory.Count < maxInventorySize)
{
// This is a new item and we have space
inventory.Add(newItem);
OnInventoryChanged?.Invoke();
return true;
}
// Inventory is full
Debug.Log("Inventory is full!");
return false;
}
// Remove an item from the inventory
public bool RemoveItem(string itemName, int quantity = 1)
{
InventoryItem item = inventory.Find(i => i.Name == itemName);
if (item != null)
{
if (item.Quantity > quantity)
{
// Reduce the quantity
item.Quantity -= quantity;
}
else
{
// Remove the item completely
inventory.Remove(item);
}
OnInventoryChanged?.Invoke();
return true;
}
return false;
}
// Get all items of a specific type
public List<InventoryItem> GetItemsByType(string type)
{
return inventory.FindAll(item => item.Name.Contains(type));
}
// Sort inventory by name
public void SortByName()
{
inventory.Sort((a, b) => string.Compare(a.Name, b.Name));
OnInventoryChanged?.Invoke();
}
// Sort inventory by quantity
public void SortByQuantity()
{
inventory.Sort((a, b) => b.Quantity.CompareTo(a.Quantity));
OnInventoryChanged?.Invoke();
}
// Get the entire inventory
public List<InventoryItem> GetInventory()
{
return new List<InventoryItem>(inventory); // Return a copy to prevent external modification
}
}
Performance Considerations
When working with List<T>
, keep these performance considerations in mind:
-
Capacity Management: Setting an appropriate initial capacity can reduce the overhead of resizing.
-
Insertion and Removal: Adding or removing elements at the end of the list is fast (O(1) amortized), but doing so at the beginning or middle requires shifting elements (O(n)).
-
Searching: Methods like
Contains
,IndexOf
, andFind
perform linear searches (O(n)). For frequent lookups, consider using aDictionary<TKey, TValue>
orHashSet<T>
instead. -
Memory Usage: Lists consume more memory than arrays due to their dynamic nature and additional functionality.
-
Iteration: For performance-critical code, using a traditional
for
loop with direct indexing can be faster thanforeach
:
// Slightly more efficient for large lists in performance-critical code
for (int i = 0; i < myList.Count; i++)
{
// Process myList[i]
}
// More readable but slightly less efficient
foreach (var item in myList)
{
// Process item
}
List<T> vs. Arrays: When to Use Each
While List<T>
is more flexible than arrays, there are still cases where arrays might be preferable:
Use Arrays When:
- You need maximum performance
- The collection size is fixed and known in advance
- You want direct serialization in Unity's Inspector
- Memory usage is a critical concern
Use List<T> When:
- You need a dynamic collection size
- You frequently add or remove elements
- You need the rich functionality of the List API
- You want cleaner, more maintainable code
Practical Example: Wave-Based Enemy Spawner
Let's implement a wave-based enemy spawner using List<T>
:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class EnemyWave
{
public string WaveName;
public List<GameObject> EnemyPrefabs;
public int EnemyCount;
public float SpawnInterval;
public float WaveDuration;
}
public class WaveSpawner : MonoBehaviour
{
[SerializeField] private List<EnemyWave> waves = new List<EnemyWave>();
[SerializeField] private Transform[] spawnPoints;
private List<GameObject> activeEnemies = new List<GameObject>();
private int currentWaveIndex = 0;
private bool isSpawning = false;
void Start()
{
StartNextWave();
}
void StartNextWave()
{
if (currentWaveIndex < waves.Count)
{
StartCoroutine(SpawnWave(waves[currentWaveIndex]));
currentWaveIndex++;
}
else
{
Debug.Log("All waves completed!");
}
}
IEnumerator SpawnWave(EnemyWave wave)
{
isSpawning = true;
Debug.Log($"Starting wave: {wave.WaveName}");
float elapsedTime = 0f;
int enemiesSpawned = 0;
while (elapsedTime < wave.WaveDuration && enemiesSpawned < wave.EnemyCount)
{
// Choose a random enemy prefab from this wave
GameObject prefab = wave.EnemyPrefabs[UnityEngine.Random.Range(0, wave.EnemyPrefabs.Count)];
// Choose a random spawn point
Transform spawnPoint = spawnPoints[UnityEngine.Random.Range(0, spawnPoints.Length)];
// Spawn the enemy
GameObject enemy = Instantiate(prefab, spawnPoint.position, spawnPoint.rotation);
activeEnemies.Add(enemy);
enemiesSpawned++;
// Wait for the next spawn
yield return new WaitForSeconds(wave.SpawnInterval);
elapsedTime += wave.SpawnInterval;
}
isSpawning = false;
// Wait until all enemies from this wave are defeated
yield return StartCoroutine(WaitForWaveCompletion());
// Start the next wave
StartNextWave();
}
IEnumerator WaitForWaveCompletion()
{
// Wait until all enemies are defeated and we're not currently spawning
while (activeEnemies.Count > 0 || isSpawning)
{
// Clean up any null references (enemies that were destroyed)
activeEnemies.RemoveAll(enemy => enemy == null);
yield return new WaitForSeconds(1f);
}
Debug.Log("Wave completed!");
yield return new WaitForSeconds(3f); // Brief pause between waves
}
void Update()
{
// Display debug info
if (activeEnemies.Count > 0)
{
Debug.Log($"Active enemies: {activeEnemies.Count}");
}
}
}
This example demonstrates several uses of List<T>
:
- A serializable list of
EnemyWave
objects configured in the Inspector - A list of enemy prefabs within each wave
- A runtime list tracking active enemies
- Using
RemoveAll
to clean up destroyed enemies
Conclusion
List<T>
is one of the most versatile and commonly used collection types in C#. It provides a dynamic, flexible alternative to arrays with a rich set of methods for adding, removing, searching, and manipulating elements.
Key points to remember:
List<T>
can grow and shrink dynamically- It provides indexed access like arrays
- It offers numerous methods for common operations
- It's type-safe through generics
- It's widely used in game development for managing collections of objects
In the next section, we'll explore Dictionary<TKey, TValue>
, another essential collection type that excels at fast lookups using key-value pairs.