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>andDictionary<K,V>; generics are reified, so no boxing.- Collection expressions (
[1, 2, 3]) build any of them, and^1and1..3index from the end. - A dictionary's indexer throws on a missing key -
TryGetValueorGetValueOrDefault. - LINQ is lazy and re-runs on every enumeration;
ToList()pays once. OrderBy().ThenBy(),GroupBy,Any,AllandCountreplace the loops.- Expose
IReadOnlyList<T>, and useImmutableArray<T>when it truly must not change.