Arrays, Lists and Collections
Storing groups of values in C#: arrays, List<T>, Dictionary<TKey,TValue> and HashSet<T>, collection expressions, indexes and ranges, and choosing the right collection.
Arrays
An
array
stores a fixed number of items of one type, one after another in
memory, numbered from 0. Its type is written as the item type
followed by square brackets: decimal[] is an array of
decimals. You create it either with its items, or with
new T[length], which fills every slot with the type's
default value (0, false, or
null for reference types).
decimal[] tariffs = [0.32m, 0.28m, 0.21m]; // day, evening, night
int[] dailyKwh = new int[7];
Console.WriteLine(tariffs.Length);
Console.WriteLine(tariffs[0]);
tariffs[2] = 0.19m;
dailyKwh[0] = 11;
Console.WriteLine(string.Join(" | ", tariffs));
Console.WriteLine(string.Join(",", dailyKwh));
Console.WriteLine(tariffs);
3 0.32 0.32 | 0.28 | 0.19 11,0,0,0,0,0,0 System.Decimal[]
The length is fixed when the array is created; there is no
Add. Reading or writing outside
0 … Length - 1 throws an
IndexOutOfRangeException, and
^1 and ranges such as [1..3]
give you the last item and slices.
Arrays are reference types
An array is an object on the heap, and a variable of an array type holds a reference to it (see value types and reference types). Assigning the variable to another variable copies the reference, not the items: both names then refer to the same array.
int[] original = [412, 415, 419];
int[] alias = original;
int[] copy = [.. original];
alias[0] = 0;
Console.WriteLine(string.Join(",", original));
Console.WriteLine(string.Join(",", copy));
Console.WriteLine(original == alias);
Console.WriteLine(original == copy);
0,415,419 412,415,419 True False
== on arrays compares references, not contents: two
arrays with the same items are still two different objects. To
compare contents, use original.SequenceEqual(copy) from
LINQ. The same applies to lists,
dictionaries and sets. The underlying idea is explained in
values and references.
Sorting and searching arrays
The static class Array has helpers that work on any
array. Array.Sort sorts in place;
Array.IndexOf returns the position of an item, or
-1 when it is absent.
string[] cities = ["Leiden", "Delft", "Gouda", "Breda"];
Array.Sort(cities);
Console.WriteLine(string.Join(", ", cities));
Console.WriteLine(Array.IndexOf(cities, "Gouda"));
Console.WriteLine(Array.IndexOf(cities, "Utrecht"));
Console.WriteLine(cities.Contains("Delft"));
Breda, Delft, Gouda, Leiden 2 -1 True
Multidimensional and jagged arrays
C# has two kinds of "arrays of arrays". A
rectangular array int[,] is one block with
rows and columns of equal length, indexed as
[row, col]. A jagged array
int[][] is an array whose items are separate arrays,
which may differ in length.
// load in % per substation (row) and shift (column)
int[,] load = { { 62, 97, 71 }, { 55, 58, 60 } };
Console.WriteLine(load[0, 1]);
Console.WriteLine($"{load.GetLength(0)} rows, {load.GetLength(1)} columns");
// a different number of readings per meter
int[][] readings = [[412, 415], [3020, 3044, 3101], []];
Console.WriteLine(readings[1][2]);
Console.WriteLine(readings[2].Length);
97 2 rows, 3 columns 3101 0
Collection expressions
A
collection expression
(C# 12) is a list of items in square brackets:
[0.32m, 0.28m]. The same syntax creates an array, a
List<T>, a HashSet<T>, a span
and other collection types; the compiler builds whichever type the
expression is assigned to. [] is an empty collection,
and the spread element .. other inserts all
items of another collection.
int[] north = [62, 71];
int[] south = [55, 58, 60];
int[] all = [.. north, .. south, 99];
List<int> list = [.. all];
HashSet<int> unique = [3, 1, 3, 2];
List<string> empty = [];
Console.WriteLine(string.Join(",", all));
Console.WriteLine($"{list.Count} {unique.Count} {empty.Count}");
62,71,55,58,60,99 6 3 0
Because the target type decides what is built, a collection
expression needs one. var gives it none:
var readings = [412, 415, 419];
example.cs(1,16): error CS9176: There is no target type for the collection expression.
Write the type instead:
int[] readings = [412, 415, 419];. Older code uses
new int[] { 412, 415, 419 } or
new List<int> { 412, 415, 419 } (a
collection initializer); both still work, and you will see
them a lot.
List<T>
Most of the time you do not know in advance how many items there
will be.
List<T>
is the growable collection of .NET: a
dynamic array
that keeps its items in order, allows duplicates, and gives fast
access by index. The T is a
type parameter: you choose the item type
when you use it, as in List<string> or
List<decimal>. The number of items is
Count (arrays say Length).
List<string> tickets = ["INC-20481", "INC-20482"];
tickets.Add("INC-20483");
tickets.Insert(0, "INC-20480");
tickets.AddRange(["INC-20484", "INC-20485"]);
tickets.Remove("INC-20482"); // by value
tickets.RemoveAt(tickets.Count - 1); // by index
Console.WriteLine(tickets.Count);
Console.WriteLine(string.Join(", ", tickets));
Console.WriteLine(tickets.Contains("INC-20483"));
Console.WriteLine(tickets.IndexOf("INC-20484"));
Console.WriteLine(tickets[^1]);
4 INC-20480, INC-20481, INC-20483, INC-20484 True 3 INC-20484
The type parameter is enforced by the compiler. A
List<int> only ever contains
int values, so code that reads from it never needs to
check:
List<int> readings = [412, 415];
readings.Add("419");
example.cs(2,14): error CS1503: Argument 1: cannot convert from 'string' to 'int'
How a list grows
Inside, a List<T> keeps its items in an ordinary
array that is usually larger than needed. Count is the
number of items in use; Capacity is the size of the
internal array. When an Add finds the array full, the
list allocates a new array of twice the size and copies the items
over. That copy is expensive, but it happens so rarely that adding
at the end is fast on average (its
amortized cost
is constant).
Count is what you stored; Capacity is
the room the list has reserved before it must grow again.
List<int> readings = [];
Console.WriteLine($"start: {readings.Count}/{readings.Capacity}");
foreach (int kwh in new[] { 412, 415, 419, 431, 440 })
{
readings.Add(kwh);
Console.WriteLine($"added {kwh}: {readings.Count}/{readings.Capacity}");
}
List<int> big = new(capacity: 10_000); // reserve room up front
Console.WriteLine($"{big.Count}/{big.Capacity}");
start: 0/0 added 412: 1/4 added 415: 2/4 added 419: 3/4 added 431: 4/4 added 440: 5/8 0/10000
You rarely need to think about capacity. When you know roughly how many items are coming, passing the capacity to the constructor avoids the repeated copying. Inserting or removing near the start of a big list is slow, though, because every later item has to shift one place.
Sorting and finding
Sort() sorts a list in place in the natural order of
its items. To sort by something else, pass a comparison as a
lambda:
(a, b) => a.Kwh.CompareTo(b.Kwh) returns a negative
number, zero or a positive number. Find
returns the first item that matches a condition, and
FindAll all of them.
List<Reading> readings =
[
new("MTR-0043", 980m),
new("MTR-0042", 320m),
new("MTR-0044", 512m),
];
readings.Sort((a, b) => a.Kwh.CompareTo(b.Kwh));
Console.WriteLine(string.Join(", ", readings.Select(r => r.MeterId)));
Reading? high = readings.Find(r => r.Kwh > 500m);
Console.WriteLine(high);
List<Reading> over400 = readings.FindAll(r => r.Kwh > 400m);
Console.WriteLine(over400.Count);
record Reading(string MeterId, decimal Kwh);
MTR-0042, MTR-0044, MTR-0043
Reading { MeterId = MTR-0044, Kwh = 512 }
2
Find returns null (for a class or record)
when nothing matches, hence the ? in
Reading?; see
nullable reference types. For anything more involved, such as sorting on two keys or
sorting without changing the original list, LINQ's
OrderBy and ThenBy are clearer.
Dictionary<TKey,TValue>
A
Dictionary<TKey,TValue>
stores key-value pairs and finds the value for a key in roughly
constant time, however many pairs there are. It is a
hash table, like a Python
dictionary. Each key occurs at most once; both the key type and the value
type are fixed.
Dictionary<string, decimal> tariffs = new()
{
["Day"] = 0.32m,
["Evening"] = 0.28m,
};
tariffs["Night"] = 0.21m; // add a new key
tariffs["Day"] = 0.33m; // replace an existing value
Console.WriteLine(tariffs["Day"]);
Console.WriteLine(tariffs.Count);
Console.WriteLine(tariffs.ContainsKey("Weekend"));
tariffs.Remove("Evening");
foreach (var (period, price) in tariffs.OrderBy(pair => pair.Key))
{
Console.WriteLine($"{period}: {price:F2}");
}
0.33 3 False Day: 0.33 Night: 0.21
Assigning through the indexer adds the key or replaces its value.
Add(key, value) instead throws an
ArgumentException when the key already exists, which is
useful when a duplicate would be a bug. Looping over a dictionary
gives KeyValuePair items that you can deconstruct into
two variables, as above.
Missing keys and the TryGetValue pattern
Reading a key that does not exist with the indexer throws an
exception, just as Python raises KeyError:
Dictionary<string, decimal> tariffs = new() { ["Day"] = 0.32m };
Console.WriteLine(tariffs["Weekend"]);
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'Weekend' was not present in the dictionary.
When a key may be absent, use the
TryGetValue pattern:
TryGetValue looks the key up once, returns
true or false, and hands back the value
through an
out parameter. Checking ContainsKey first and then reading with the
indexer does the lookup twice. When a fallback value is enough,
GetValueOrDefault is shorter still.
Dictionary<string, decimal> tariffs = new() { ["Day"] = 0.32m, ["Night"] = 0.21m };
foreach (string period in new[] { "Night", "Weekend" })
{
if (tariffs.TryGetValue(period, out decimal price))
{
Console.WriteLine($"{period}: {price:F2}");
}
else
{
Console.WriteLine($"{period}: no tariff defined");
}
}
Console.WriteLine(tariffs.GetValueOrDefault("Weekend", 0.30m));
// counting: a missing key counts as 0
string[] ticketCities = ["Delft", "Leiden", "Delft", "Gouda", "Delft"];
Dictionary<string, int> perCity = [];
foreach (string city in ticketCities)
{
perCity[city] = perCity.GetValueOrDefault(city) + 1;
}
foreach (var (city, count) in perCity.OrderBy(pair => pair.Key))
{
Console.WriteLine($"{city}: {count}");
}
Night: 0.21 Weekend: no tariff defined 0.30 Delft: 3 Gouda: 1 Leiden: 1
HashSet<T>
A
HashSet<T>
is a set: an
unordered collection in which each value occurs at most once. Like a
dictionary it is a hash table, so Contains is fast even
for millions of items, whereas
List<T>.Contains has to look at every item.
Add returns false when the value was
already there, and the set operations change the set in place.
HashSet<string> offlineMeters = ["MTR-0042", "MTR-0043"];
Console.WriteLine(offlineMeters.Add("MTR-0044"));
Console.WriteLine(offlineMeters.Add("MTR-0042"));
Console.WriteLine(offlineMeters.Contains("MTR-0043"));
HashSet<string> visitedToday = ["MTR-0043", "MTR-0099"];
HashSet<string> stillOffline = [.. offlineMeters];
stillOffline.ExceptWith(visitedToday); // remove the visited ones
Console.WriteLine(string.Join(", ", stillOffline.Order()));
HashSet<string> both = [.. offlineMeters];
both.IntersectWith(visitedToday); // keep only common ones
Console.WriteLine(string.Join(", ", both));
True False True MTR-0042, MTR-0044 MTR-0043
A set has no indexes and no reliable order, which is why the example
sorts with Order() before printing.
UnionWith adds all values of another collection, and
SortedSet<T> keeps its items sorted at all times.
Queues, stacks and other collections
The namespace System.Collections.Generic (always
available, no using needed) contains more specialized
collections. Two you will use regularly are the
queue and
the stack.
Queue<string> waiting = new();
waiting.Enqueue("INC-20481");
waiting.Enqueue("INC-20482");
waiting.Enqueue("INC-20483");
Console.WriteLine($"next: {waiting.Dequeue()}, then {waiting.Peek()}, {waiting.Count} left");
Stack<string> undo = new();
undo.Push("set tariff Day");
undo.Push("set tariff Night");
Console.WriteLine($"undo: {undo.Pop()}");
next: INC-20481, then INC-20482, 2 left undo: set tariff Night
-
Queue<T>: first in, first out, such as tickets waiting for an agent.PriorityQueue<TElement,TPriority>hands out the item with the lowest priority value first. -
Stack<T>: last in, first out, such as undo history. -
SortedDictionary<TKey,TValue>andSortedSet<T>: always sorted by key. -
LinkedList<T>: fast insertion in the middle, but no index; rarely the best choice in practice. -
The
System.Collections.Concurrentnamespace (ConcurrentDictionary,ConcurrentQueue): safe to use from several threads at once; see asynchronous programming.
Choosing the right collection
The right collection depends on how you will use the data most often. The table gives the typical cost of common operations in Big O notation; "n" is the number of items.
| Collection | Use it for | By index | Contains / lookup | Add |
|---|---|---|---|---|
T[] |
a fixed number of items, method results, performance | O(1) | O(n) | not possible |
List<T> |
an ordered sequence that grows (the default choice) | O(1) | O(n) | O(1) at the end, on average |
Dictionary<TKey,TValue> |
looking up a value by a key | no index | O(1) on average | O(1) on average |
HashSet<T> |
unique values, fast "is it in there?" | no index | O(1) on average | O(1) on average |
Queue<T> / Stack<T>
|
processing items in arrival or reverse order | no index | O(n) | O(1) |
A practical rule: start with List<T>; switch to a
Dictionary as soon as you search the list by some ID
again and again, and to a HashSet when you only need to
know whether something is present. For parameters, accept the most
general type you need, often
IEnumerable<T>
or IReadOnlyList<T>, so callers can pass any
collection. Filtering, grouping and sorting collections is the
subject of LINQ.