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).

A list with Count 5 and Capacity 8. Its internal array has eight slots; the first five hold the readings 412, 415, 419, 431 and 440, the last three are unused. Adding a ninth item would make the list copy everything to a new array of 16 slots. List<int> internal array 412 415 419 431 440 Count = 5 Capacity = 8
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> and SortedSet<T>: always sorted by key.
  • LinkedList<T>: fast insertion in the middle, but no index; rarely the best choice in practice.
  • The System.Collections.Concurrent namespace (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.