Collections and Iterators
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Collections are where most C# programs spend their data. The BCL offers a deliberately small set of concrete types over a rich set of interfaces, so code can accept the weakest contract it needs and callers can supply whatever storage suits them.
Arrays
An array is a fixed-length, zero-indexed block of same-typed elements. It is a reference type even when its elements are values.
public static class ArrayBasics
{
public static void Run()
{
int[] byLength = new int[3]; // all elements are default(int) == 0
int[] byInitializer = { 1, 2, 3 }; // classic array initializer
int[] explicitly = new int[] { 1, 2, 3 };
int[] targetTyped = [1, 2, 3]; // collection expression (C# 12) -- preferred
Console.WriteLine(targetTyped.Length); // 3
Console.WriteLine(targetTyped[0]); // 1
Console.WriteLine(byLength[2] + byInitializer[2] + explicitly[2]);
// Array's static helpers cover the everyday operations.
int[] values = [5, 3, 9, 1];
Array.Sort(values);
Console.WriteLine(string.Join(",", values)); // 1,3,5,9
Console.WriteLine(Array.IndexOf(values, 5)); // 2
Console.WriteLine(Array.BinarySearch(values, 9)); // 3 -- requires a sorted array
int[] copy = new int[values.Length];
Array.Copy(values, copy, values.Length);
Array.Reverse(copy);
Console.WriteLine(string.Join(",", copy)); // 9,5,3,1
}
}
Multidimensional and Jagged Arrays
C# distinguishes a rectangular array (one object, several dimensions) from a jagged array (an array of array references, rows of independent length):
public static class ArrayShapes
{
public static void Run()
{
// Rectangular: a single allocation, indexed with one bracket pair.
int[,] grid = new int[2, 3];
grid[1, 2] = 7;
Console.WriteLine($"{grid.GetLength(0)}x{grid.GetLength(1)} rank {grid.Rank}"); // 2x3 rank 2
int[,] initialised =
{
{ 1, 2, 3 },
{ 4, 5, 6 },
};
Console.WriteLine(initialised[1, 1]); // 5
// Jagged: an array of arrays -- rows may differ in length, and may be null.
int[][] jagged =
[
[1],
[2, 3],
[4, 5, 6],
];
Console.WriteLine(jagged[2][1]); // 5
Console.WriteLine(jagged.Length); // 3 -- rows
Console.WriteLine(jagged[2].Length); // 3 -- that row's columns
}
}
Jagged arrays are usually faster: indexing a rectangular array cannot use the same bounds-check elision the JIT applies to single-dimensional ones. Rectangular arrays are the better fit when the shape really is rectangular and interop or matrix code depends on contiguous layout.
Array Covariance
Arrays are covariant: string[] is assignable to object[]. This predates generics and is unsound — the
check moves to run time:
public static class ArrayCovariance
{
public static void Run()
{
string[] names = ["ada", "alan"];
object[] asObjects = names; // legal, but a loaded gun
try
{
asObjects[0] = 42; // compiles; throws at run time
}
catch (ArrayTypeMismatchException)
{
Console.WriteLine("array covariance caught at run time");
}
}
}
Prefer IReadOnlyList<T>, which is covariant safely because it has no setter. See
Generics for the variance rules.
Collection Expressions (C# 12)
[…] builds any collection type the target expects — array, List<T>, Span<T>, ImmutableArray<T>, or any
type with a [CollectionBuilder] attribute. The spread element .. inlines another sequence:
public static class CollectionExpressions
{
public static void Run()
{
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
ImmutableArray<int> immutable = [1, 2, 3];
HashSet<int> set = [1, 2, 2, 3]; // duplicates collapse
int[] head = [1, 2];
int[] tail = [5, 6];
int[] joined = [..head, 3, 4, ..tail]; // spread: 1,2,3,4,5,6
Console.WriteLine(string.Join(",", joined));
int[] empty = [];
Console.WriteLine($"{list.Count} {span.Length} {immutable.Length} {set.Count} {empty.Length}");
// The target type decides -- the same syntax, three different allocations.
Print([1, 2, 3]);
}
private static void Print(ReadOnlySpan<int> values) => Console.WriteLine(values.Length);
}
The compiler is free to choose the cheapest construction for the target type — for a ReadOnlySpan<int> of
constants it can emit a reference into the assembly’s data section with no allocation at all.
Collection-Expression Arguments (C# 15 preview)
|
Preview feature — C# 15 / .NET 11
This requires a .NET 11 preview SDK and |
A collection expression cannot currently pass arguments to the collection it builds — there is no way to say
"a Dictionary with this comparer" or "a List with this capacity". C# 15 adds a leading with(…) element
carrying those arguments:
// The `with(...)` element supplies constructor arguments to the built collection.
HashSet<string> caseInsensitive = [with(StringComparer.OrdinalIgnoreCase), "Ada", "ADA"];
Console.WriteLine(caseInsensitive.Count); // 1
List<int> presized = [with(capacity: 1024), 1, 2, 3];
Until it ships, spell the construction out: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Ada" }.
The Everyday Collections
| Type | Ordering | Lookup | Use it when |
|---|---|---|---|
|
Insertion order |
By index, O(1) |
The default sequence — growable, contiguous, cache-friendly |
|
Unordered |
By key, O(1) average |
Keyed lookup dominates |
|
Unordered |
Membership, O(1) average |
Uniqueness and set algebra |
|
FIFO |
Ends only |
Work items processed in arrival order |
|
LIFO |
Top only |
Undo, backtracking, depth-first traversal |
|
Insertion order |
Sequential, O(n) |
Frequent insertion/removal in the middle given a node |
|
Key order |
By key, O(log n) |
Keys must stay sorted; balanced-tree updates |
|
Key order |
By key, O(log n) |
Sorted, mostly read; less memory, slower inserts |
|
Sorted |
Membership, O(log n) |
Uniqueness and order, range queries |
|
By priority |
Minimum only |
Scheduling, Dijkstra-style algorithms |
public static class EverydayCollections
{
public static void Run()
{
var list = new List<string> { "b", "a" };
list.Add("c");
list.Insert(0, "z");
list.Remove("b");
list.Sort();
Console.WriteLine(string.Join(",", list)); // a,c,z
var ages = new Dictionary<string, int> { ["ada"] = 36, ["alan"] = 41 };
ages["grace"] = 45; // add or overwrite
Console.WriteLine(ages.TryGetValue("ada", out int age) ? age : -1); // 36
Console.WriteLine(ages.GetValueOrDefault("nobody", -1)); // -1
Console.WriteLine(ages.ContainsKey("alan")); // True
var seen = new HashSet<int> { 1, 2, 3 };
Console.WriteLine(seen.Add(3)); // False -- already present
seen.UnionWith([3, 4]);
seen.IntersectWith([2, 3, 4]);
Console.WriteLine(string.Join(",", seen.Order())); // 2,3,4
var queue = new Queue<string>();
queue.Enqueue("first");
queue.Enqueue("second");
Console.WriteLine(queue.Dequeue()); // first
Console.WriteLine(queue.Peek()); // second
var stack = new Stack<int>([1, 2, 3]);
Console.WriteLine(stack.Pop()); // 3
var jobs = new PriorityQueue<string, int>();
jobs.Enqueue("low", 10);
jobs.Enqueue("urgent", 1);
jobs.Enqueue("medium", 5);
Console.WriteLine(jobs.Dequeue()); // urgent -- lowest priority value wins
}
}
Comparers in Collections
Hash-based and sorted collections take a comparer, which is how you control keying and ordering without touching the element type:
public sealed record Employee(string Name, int Level);
public static class ComparersInCollections
{
public static void Run()
{
// Case-insensitive keys: the comparer, not the key type, decides equality.
var byName = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["Ada"] = 1,
};
Console.WriteLine(byName.ContainsKey("ADA")); // True
// A sorted set ordered by a projection.
var byLevel = new SortedSet<Employee>(
Comparer<Employee>.Create((a, b) => a.Level.CompareTo(b.Level)))
{
new Employee("ada", 3),
new Employee("alan", 1),
};
Console.WriteLine(byLevel.Min?.Name); // alan
// EqualityComparer<T>.Default is what a Dictionary uses when you give it none:
// IEquatable<T> if implemented, otherwise object.Equals.
Console.WriteLine(EqualityComparer<Employee>.Default.Equals(
new Employee("ada", 3), new Employee("ada", 3))); // True -- records have value equality
}
}
See Equality and Operator Overloading
for what a correct Equals/GetHashCode pair owes a hash-based collection.
The IEnumerable<T> Contract
Everything foreach-able implements, or at least duck-types, a pair of interfaces:
public interface IEnumerableSketch<out T>
{
IEnumeratorSketch<T> GetEnumerator();
}
public interface IEnumeratorSketch<out T> : IDisposable
{
T Current { get; }
bool MoveNext();
}
GetEnumerator()
foreach works"] COL["ICollection<T>
Count, Add, Remove, Contains"] LST["IList<T>
indexer, Insert, RemoveAt"] SET["ISet<T>
UnionWith, IntersectWith…"] DICT["IDictionary<TKey,TValue>
this[key], TryGetValue"] ROC["IReadOnlyCollection<T>
Count"] ROL["IReadOnlyList<T>
indexer (get only)"] ROD["IReadOnlyDictionary<TKey,TValue>"] IE --> COL IE --> ROC COL --> LST COL --> SET COL --> DICT ROC --> ROL ROC --> ROD LST -.->|"implemented by"| IMPL1["List<T>, T[]"] DICT -.->|"implemented by"| IMPL2["Dictionary<TKey,TValue>"] SET -.->|"implemented by"| IMPL3["HashSet<T>, SortedSet<T>"] style IE fill:#eef6f0,stroke:#6fa383 style ROC fill:#f7f9fc,stroke:#8fa4bd style ROL fill:#f7f9fc,stroke:#8fa4bd style ROD fill:#f7f9fc,stroke:#8fa4bd
Accept the weakest interface that does the job: IEnumerable<T> for a parameter you only iterate,
IReadOnlyList<T> when you need Count and indexing, a concrete type only when you must mutate it.
How foreach Desugars
foreach is syntax over that contract. The compiler does not require the interface — a type with a suitable
public GetEnumerator() is enough, which is how Span<T> works with foreach despite not implementing
IEnumerable<T>:
public static class ForeachDesugaring
{
public static void Explicitly(IEnumerable<int> source)
{
// foreach (int n in source) { Console.WriteLine(n); }
// is compiled to approximately:
IEnumerator<int> enumerator = source.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
int n = enumerator.Current;
Console.WriteLine(n);
}
}
finally
{
enumerator?.Dispose(); // this is why iterators can run cleanup code
}
}
}
// A custom type that `foreach` accepts without implementing any interface.
public readonly struct Countdown
{
private readonly int _from;
public Countdown(int from) => _from = from;
public Enumerator GetEnumerator() => new Enumerator(_from);
public struct Enumerator
{
private int _current;
public Enumerator(int from) => _current = from + 1;
public int Current => _current;
public bool MoveNext() => --_current >= 0;
}
}
The iteration variable is read-only inside the loop, and — since C# 5 — a fresh variable each iteration, which matters when a lambda captures it (see Delegates, Lambdas and Events).
Iterators: yield return
Writing an enumerator by hand is tedious. yield return makes the compiler generate the state machine for you:
public static class Iterators
{
// Each `yield return` hands one element back and suspends; execution resumes here
// on the next MoveNext() call.
public static IEnumerable<int> Fibonacci(int count)
{
int a = 0, b = 1;
for (int i = 0; i < count; i++)
{
yield return a;
(a, b) = (b, a + b);
}
}
public static IEnumerable<string> ReadLinesLazily(IEnumerable<string> source)
{
foreach (string line in source)
{
if (line.Length == 0)
{
yield break; // stop the sequence early
}
yield return line.Trim();
}
}
public static void Run()
{
Console.WriteLine(string.Join(",", Fibonacci(8))); // 0,1,1,2,3,5,8,13
Console.WriteLine(string.Join(",", ReadLinesLazily([" a ", "b", "", "c"]))); // a,b
}
}
Laziness, and Its Consequences
An iterator method’s body does not run when you call it — it runs when something enumerates the result. Two consequences bite regularly:
public static class LazinessTraps
{
public static IEnumerable<int> Parsed(IEnumerable<string> input)
{
// This guard does NOT run at call time -- it runs at first MoveNext().
ArgumentNullException.ThrowIfNull(input);
foreach (string text in input)
{
Console.WriteLine($"parsing {text}"); // proves when work happens
yield return int.Parse(text);
}
}
// Fix: a non-iterator wrapper validates eagerly and delegates to a private iterator.
public static IEnumerable<int> ParsedEagerlyValidated(IEnumerable<string> input)
{
ArgumentNullException.ThrowIfNull(input);
return Iterate(input);
static IEnumerable<int> Iterate(IEnumerable<string> input)
{
foreach (string text in input)
{
yield return int.Parse(text);
}
}
}
public static void Run()
{
IEnumerable<int> query = Parsed(["1", "2"]);
Console.WriteLine("nothing parsed yet");
Console.WriteLine(query.Sum()); // now it parses
Console.WriteLine(query.Sum()); // and parses AGAIN -- re-enumeration re-runs the work
}
}
-
Deferred side effects. Argument validation, logging and resource acquisition in an iterator happen at first enumeration, not at call time. Split the method as shown above when that matters.
-
Multiple enumeration. Every
foreachover the sameIEnumerable<T>re-executes it. Materialise withToList()/ToArray()when you will walk a sequence more than once, or when the source is a network call.
An iterator’s finally blocks run when the enumerator is disposed — which foreach guarantees, including on
an early break — so using inside an iterator is safe.
Read-Only, Immutable and Frozen Collections
Three distinct guarantees, often confused:
public static class ImmutabilityLevels
{
public static void Run()
{
var mutable = new List<int> { 1, 2, 3 };
// 1. A read-only VIEW: the caller cannot mutate it, but the underlying list can still change.
IReadOnlyList<int> view = mutable;
mutable.Add(4);
Console.WriteLine(view.Count); // 4 -- the view saw the change
// A defensive wrapper makes the view's read-only-ness explicit at run time too.
var wrapper = mutable.AsReadOnly();
Console.WriteLine(wrapper.Count); // 4
// 2. IMMUTABLE: mutation returns a new collection; the original never changes.
ImmutableList<int> immutable = [1, 2, 3];
ImmutableList<int> extended = immutable.Add(4);
Console.WriteLine($"{immutable.Count} {extended.Count}"); // 3 4
ImmutableArray<int> array = [1, 2, 3];
Console.WriteLine(array.Length);
// 3. FROZEN: immutable AND optimised for reads, built once, never updated.
FrozenDictionary<string, int> frozen = new Dictionary<string, int>
{
["ada"] = 1,
["alan"] = 2,
}.ToFrozenDictionary();
Console.WriteLine(frozen["ada"]); // 1 -- faster lookups than Dictionary
FrozenSet<string> frozenSet = new[] { "a", "b" }.ToFrozenSet();
Console.WriteLine(frozenSet.Contains("a"));
}
}
Use a read-only interface for parameters and properties, an immutable collection when sharing across threads or when value semantics matter, and a frozen collection for a lookup table built at startup and read for the process’s lifetime.
Indices and Ranges
^ counts from the end; .. builds a range. Both work on arrays, strings and spans natively, and on any type
with the right indexer:
public static class IndicesAndRanges
{
public static void Run()
{
int[] values = [0, 1, 2, 3, 4, 5];
Console.WriteLine(values[^1]); // 5 -- last element
Console.WriteLine(values[^2]); // 4
Console.WriteLine(string.Join(",", values[1..4])); // 1,2,3 -- end is exclusive
Console.WriteLine(string.Join(",", values[..3])); // 0,1,2
Console.WriteLine(string.Join(",", values[3..])); // 3,4,5
Console.WriteLine(string.Join(",", values[..])); // a copy of the whole array
Index secondFromEnd = ^2;
Range middle = 1..^1;
Console.WriteLine(values[secondFromEnd]); // 4
Console.WriteLine(string.Join(",", values[middle])); // 1,2,3,4
// On a List<T>, use CollectionsMarshal or a span; List<T> has no Range indexer.
List<int> list = [0, 1, 2, 3];
Console.WriteLine(list[^1]); // 3 -- Index works, Range does not
}
}
Ranging an array allocates a new array; ranging a span does not — which is the next section’s point.
Span<T> as a Collection View
Span<T> is a window onto memory you already have — an array, a stackalloc buffer, unmanaged memory — sliced without copying:
public static class SpansAsViews
{
public static int SumOfMiddle(int[] values)
{
ReadOnlySpan<int> span = values; // no copy
ReadOnlySpan<int> middle = span[1..^1]; // still no copy -- just offset + length
int total = 0;
foreach (int n in middle)
{
total += n;
}
return total;
}
public static void Run()
{
Console.WriteLine(SumOfMiddle([1, 2, 3, 4])); // 5
// Parsing without allocating substrings.
ReadOnlySpan<char> line = "name=value".AsSpan();
int separator = line.IndexOf('=');
Console.WriteLine($"{line[..separator]} -> {line[(separator + 1)..]}");
}
}
A Span<T> is a ref struct: it cannot be boxed, stored in a field of a class, captured by a lambda, or used
across an await. See
Unsafe Code, Spans and Performance.
Thread-Safe Collections
List<T> and Dictionary<TKey,TValue> are safe for concurrent readers only. For concurrent writers use the
System.Collections.Concurrent types — ConcurrentDictionary<TKey,TValue>, ConcurrentQueue<T>,
ConcurrentBag<T>, BlockingCollection<T> — or a Channel<T> for producer/consumer pipelines. See
Threads and Synchronization.
See Also
-
LINQ — the query operators layered over
IEnumerable<T>. -
Generics — constraints and variance on collection interfaces.
-
Pattern Matching — list and slice patterns.
-
Async and Await —
IAsyncEnumerable<T>andawait foreach. -
Equality and Operator Overloading — what hash-based collections require of a key type.
References
-
github.com/dotnet/csharplang — the collection-expression arguments proposal.