Collections and LINQ

Article · 13:00

Three collections carry almost every C# program: List for order, HashSet for membership, Dictionary for lookup. Why C# generics are not erased, which dictionary access throws and which does not, and what deferred execution means in LINQ.

Collections and LINQ

Three collections carry almost every C# program: List<T> for order, HashSet<T> for membership, Dictionary<K,V> for lookup. All three are generic, and unlike Java's, C#'s generics are not erased - a List<int> knows it holds int at runtime, and stores them unboxed.

List<string> tasks = ["write", "review", "write"];      // collection expression
HashSet<string> tags = ["csharp", "basics", "csharp"];  // duplicates collapse
Dictionary<string, int> stock = new() { ["apples"] = 3, ["pears"] = 0 };

Console.WriteLine(string.Join(", ", tasks));
Console.WriteLine(tags.Count);
Console.WriteLine(stock["apples"]);

tasks.Add("publish");
tasks.Insert(0, "plan");
tasks.Remove("review");
Console.WriteLine($"{tasks.Count}: {string.Join(" -> ", tasks)}");

Console.WriteLine(tasks[0]);
Console.WriteLine(tasks[^1]);          // ^1 is "one from the end"
Console.WriteLine(string.Join(",", tasks[1..3]));   // a range, end exclusive

Reading a dictionary without an exception

var stock = new Dictionary<string, int> { ["apples"] = 3 };

// The indexer THROWS on a missing key - unlike Java's map, which returns null.
try
{
    Console.WriteLine(stock["plums"]);
}
catch (KeyNotFoundException)
{
    Console.WriteLine("the indexer throws on a missing key");
}

// So read with TryGetValue or GetValueOrDefault.
if (stock.TryGetValue("apples", out int apples))
{
    Console.WriteLine($"apples: {apples}");
}
Console.WriteLine(stock.GetValueOrDefault("plums", 0));

stock["plums"] = 7;                     // the indexer inserts or overwrites
stock.TryAdd("apples", 99);             // only if missing, so no change
Console.WriteLine(string.Join(", ", stock.Select(e => $"{e.Key}={e.Value}")));

foreach ((string name, int count) in stock)   // deconstructed in the foreach
{
    Console.WriteLine($"{name}: {count}");
}

Arrays, spans and the immutable ones

int[] numbers = [5, 3, 9, 1];
Array.Sort(numbers);
Console.WriteLine(string.Join(",", numbers));

// A Span<T> is a view over memory with no copy - slicing it allocates nothing.
Span<int> middle = numbers.AsSpan(1, 2);
middle[0] = 100;
Console.WriteLine(string.Join(",", numbers));     // the array changed

// Immutable, when a collection must not change after construction.
System.Collections.Immutable.ImmutableArray<int> frozen = [1, 2, 3];
Console.WriteLine(string.Join(",", frozen.Add(4)));   // a NEW array
Console.WriteLine(string.Join(",", frozen));          // the original stands

// IReadOnlyList is the type to expose: callers can read, not add.
IReadOnlyList<int> exposed = numbers;
Console.WriteLine(exposed.Count);

LINQ is the query language, and it is lazy

Where, Select and friends build a query. Nothing runs until something enumerates it - a foreach, a ToList, a Count.

var source = new[] { 1, 2, 3, 4, 5, 6 };

var query = source
    .Where(n => { Console.WriteLine($"  testing {n}"); return n % 2 == 0; })
    .Select(n => n * n);

Console.WriteLine("nothing has run yet");
Console.WriteLine(string.Join(",", query));

// And it runs AGAIN on every enumeration - which is the trap.
Console.WriteLine($"count: {query.Count()}");

// ToList() pays for the work once.
var materialised = query.ToList();
Console.WriteLine($"{materialised.Count} without re-running anything");

The operations worth knowing by name

// The record is declared at the BOTTOM of this file, not the top: in a file
// using top-level statements, every type declaration must come after the last
// statement.
Learner[] learners =
[
    new("Ada", "UK", 95),
    new("Grace", "US", 88),
    new("Alan", "UK", 72),
    new("Katherine", "US", 95),
];

Console.WriteLine(string.Join(", ", learners.Select(l => l.Name)));
Console.WriteLine(learners.Count(l => l.Score > 80));
Console.WriteLine(learners.Any(l => l.Score == 100));
Console.WriteLine(learners.All(l => l.Score > 50));
Console.WriteLine(learners.Max(l => l.Score));
Console.WriteLine(learners.Average(l => l.Score));

// OrderBy then ThenBy, which reads like the sort you meant.
foreach (var learner in learners.OrderByDescending(l => l.Score).ThenBy(l => l.Name))
{
    Console.WriteLine($"  {learner.Name} {learner.Score}");
}

// GroupBy, and the query syntax that is the same thing spelled differently.
foreach (var group in learners.GroupBy(l => l.Country))
{
    Console.WriteLine($"{group.Key}: {string.Join(", ", group.Select(l => l.Name))}");
}

var uk = from l in learners
         where l.Country == "UK"
         orderby l.Score descending
         select l.Name;
Console.WriteLine(string.Join(", ", uk));

// First throws when nothing matches; FirstOrDefault hands back null.
Console.WriteLine(learners.FirstOrDefault(l => l.Score > 99)?.Name ?? "nobody");

// ToDictionary throws on a duplicate key, which is better than silently dropping.
var byName = learners.ToDictionary(l => l.Name, l => l.Score);
Console.WriteLine(byName["Ada"]);

record Learner(string Name, string Country, int Score);

What to take away

  • List<T>, HashSet<T> and Dictionary<K,V>; generics are reified, so no boxing.
  • Collection expressions ([1, 2, 3]) build any of them, and ^1 and 1..3 index from the end.
  • A dictionary's indexer throws on a missing key - TryGetValue or GetValueOrDefault.
  • LINQ is lazy and re-runs on every enumeration; ToList() pays once.
  • OrderBy().ThenBy(), GroupBy, Any, All and Count replace the loops.
  • Expose IReadOnlyList<T>, and use ImmutableArray<T> when it truly must not change.