Generic List

Understand how to use the generic List<T> class to store, manipulate, manage strongly typed collections dynamically, and securely expose data using read-only interfaces.

While the ArrayList class solved the fixed-size limitations of arrays, it introduced type safety problems. .NET developers addressed the concerns with a set of generic collection classes.

This lesson focuses on the List<T> class. Instead of using an object[] type array like the ArrayList class does, generic collections use parameter types. In the case of List<T>, the internal array is of type T[].

Generic collections introduce strong typing. The List<int> class can only store int items, and List<string> can only store string objects. We avoid the casting issues we had with the non-generic ArrayList collection.

Syntax

The List<T> class is instantiated just like any other class:

var listOfIntegers = new List<int>();

Here, we create a new instance of a generic list that can only store integers. The internal array is of type int[].

Note: In modern C#, we can initialize collections using collection expressions, such as List<int> listOfIntegers = [];. We will use this modern syntax in our upcoming examples.

Lists we create are initially empty. Empty means that the size of the internal array is zero. When we add the first item, though, a new array of size four is created.

listOfIntegers.Add(17);

Here, we use the Add method to append the integer 17 to the collection.

Each time this array fills, it doubles in size, and values from the previous array are copied over.

Understanding this resizing behavior helps optimize memory. If we know exactly how many items we plan to store, we can set the initial capacity of the list:

var listOfIntegers = new List<int>(500);

Here, we instantiate a list and explicitly set its starting capacity to 500 to avoid unnecessary memory reallocations. The List<int>(500) class initializes an int[] array of size 500.

Basic operations

Here are some of the most common operations we can perform with List<T>:

  • We can add an item to the end of the list using the Add() method.

  • We can add items from another collection to our list using the AddRange() method.

  • We can insert an item to a specific position using the Insert() method.

  • We can remove an item using the Remove() method.

  • We can delete an item at a specific position using the RemoveAt() method.

  • We can remove all items that match a specific condition using the RemoveAll() method.

Examples

Let’s explore some examples below to clarify these methods.

Adding and removing items

We can use these methods to populate a list, retrieve items, and remove elements based on specific values or conditions.

C# 14.0
using System;
using System.Collections.Generic;
List<int> listOfIntegers = [];
// We can add items individually
listOfIntegers.Add(57);
listOfIntegers.Add(82);
// We can also add another collection using collection expressions
listOfIntegers.AddRange([24, 12, 56, 23, 78]);
// Gets the number of items
Console.WriteLine($"There are {listOfIntegers.Count} items in our list.");
// Indexers are present
Console.WriteLine($"5th number is {listOfIntegers[4]}.");
// We can remove items
// Removes the first occurrence of number 3.
bool wasFoundAndRemoved = listOfIntegers.Remove(3);
Console.WriteLine($"There was 3 in the list: {wasFoundAndRemoved}.");
// We can remove an item at a specific index
listOfIntegers.RemoveAt(5);
// We can remove all items that match a condition
// In this case, we are removing numbers less than 30
listOfIntegers.RemoveAll(x => x < 30);
Console.Write($"Items left: {listOfIntegers.Count}.");
  • Line 1: We include the necessary namespaces for generic collections.

  • Line 3: We create a new, empty integer list using a modern collection expression [].

  • Lines 6–7: We append integers to the end of the list individually.

  • Line 10: We use AddRange combined with a collection expression to append multiple items at once.

  • Line 13: We print the total count of items.

  • Line 16: We retrieve the fifth item using standard zero-based indexing.

  • Lines 20–22: We attempt to remove the value 3. The Remove method returns a boolean indicating success.

  • Line 24: We delete the item located exactly at index 5.

  • Line 29: We use a lambda expression (predicate) with the RemoveAll method to remove all integers that are strictly less than 30.

  • Line 31: We output the final item count to the console.

Sorting and performance complexity

We could write our own algorithms to sort and search, but the List<T> class includes them by default.

When searching a list using methods like Contains() or IndexOf(), the list must inspect every item sequentially from the beginning until it finds a match. This is known as an O(n)O(n) operation in Big O notation. The “n” represents the number of items, meaning the search time grows linearly as the list grows.

For large datasets, an O(n)O(n) search becomes slow. To optimize this, we can sort the list and use BinarySearch() instead.

C# 14.0
using System.Collections.Generic;
// Instantiating using modern collection expressions
List<int> numbers = [1, 78, 0, -5, 25, 31, 90, 23, 67, 53];
// Sort in an ascending order
numbers.Sort();
PrintList(numbers);
// Searching for 31
// Returns 0-based index or a negative number if not found
// BinarySearch requires the list to be sorted
var indexOf31 = numbers.BinarySearch(31);
if (indexOf31 >= 0)
{
Console.WriteLine($"Item 31 found at index {indexOf31}");
}
void PrintList<T>(IEnumerable<T> collection)
{
foreach (var item in collection)
{
Console.Write(item + " ");
}
Console.WriteLine();
}
  • Line 4: We declare and initialize an integer list with ten unsorted values using a collection expression.

  • Line 7: We call the Sort method to arrange the internal array in ascending order.

  • Line 9: We pass the list to our local helper function to print the ordered elements.

  • Line 14: We use the BinarySearch method to find the integer 31. This method is highly optimized (O(log n) complexity) but requires the list to be sorted first.

  • Lines 16–19: We evaluate the returned index, where a positive number indicates the item's location and a negative number means the item does not exist.

  • Lines 21–28: We define a generic local function PrintList<T> to iterate through and display the collection.

Actual capacity

Earlier, we mentioned that the initial capacity of a list is zero and increases to four upon adding the first item. The following code snippet demonstrates this behavior:

C# 14.0
using System.Collections.Generic;
List<int> numbers = [];
Console.WriteLine(numbers.Capacity); // Initial capacity
numbers.Add(17);
Console.WriteLine(numbers.Capacity); // Capacity after the increase
  • Line 4: We initialize an empty generic list.

  • Line 5: We print the Capacity property, which returns 0.

  • Line 7: We add a single item to the list.

  • Line 8: We print the Capacity property again. The internal array has now been instantiated with a capacity of 4.

Our list’s capacity is four, but we have only one item inside. We could remove the excess to free up memory:

C# 14.0
using System.Collections.Generic;
List<int> numbers = [];
Console.WriteLine(numbers.Capacity);
numbers.Add(17);
Console.WriteLine(numbers.Capacity);
// Trimming excess
numbers.TrimExcess();
Console.WriteLine(numbers.Capacity);
  • Lines 3–7: We initialize an empty list, add one item, and observe the capacity grow from 0 to 4.

  • Line 10: We use the TrimExcess method to shrink the internal array so its capacity exactly matches the number of actual elements stored.

  • Line 11: We print the final capacity, which is now successfully reduced to 1.

The List<T> class contains an extensive API with many more methods than we can cover here. In this chapter, we primarily focus on the Add() and RemoveAt() methods. Feel free to consult the official documentation online to explore other methods and usage scenarios.

Defensive programming with IReadOnlyList<T>

Classes frequently use List<T> to store internal data, but returning this list directly to external calling code exposes the internal state. External callers could invoke .Add() or .Remove(), which corrupts the managed state.

To prevent this, we can expose the collection using the IReadOnlyList<T> interface. This interface only provides properties and methods for reading data (such as the Count property and the indexer), effectively making the collection read-only to external consumers.

The following example demonstrates how to protect internal data using this interface.

C# 14.0
using System.Collections.Generic;
// Program execution
ShoppingCart cart = new();
cart.AddItem("Laptop");
cart.AddItem("Headphones");
// We can successfully read from the read-only list
Console.WriteLine($"First item: {cart.Items[0]}");
// The following line would cause a compiler error:
// cart.Items.Add("Mouse");
// Class definitions are placed at the end of the file
public class ShoppingCart
{
// Internal mutable list
private readonly List<string> _items = [];
// Public read-only view
public IReadOnlyList<string> Items => _items;
public void AddItem(string item)
{
// Controlled logic can go here (e.g., validation)
_items.Add(item);
}
}
  • Lines 4–6: We execute our program logic first using top-level statements, instantiating the class and adding items via the allowed public method.

  • Line 9: We read data securely from the Items property. The compiler ensures that destructive methods like Add or Remove are hidden from the cart.Items object.

  • Line 15: We define the ShoppingCart class at the bottom of the file.

  • Line 18: We define a private, mutable List<string> to store our shopping cart items internally.

  • Line 21: We expose a public property typed as IReadOnlyList<string> that simply returns a reference to our internal list.

  • Line 23: We control exactly how items are added by forcing consumers to use our AddItem method.