Chapter 10: Collections and LINQ
The .NET libraries provide a wide range of collection types for storing and managing data. LINQ layers a fluent querying model on top, giving you expressive and composable ways to filter, shape, and transform sequences. Together they act like a toolkit for taming data, whether it comes from arrays, lists, dictionaries, or external sources that stream values.
Arrays, lists, dictionaries, and sets
Collections in .NET come in several shapes. Arrays offer compact storage and fast indexed access; lists support dynamic resizing; dictionaries map keys to values; sets track unique items. Choose the structure that matches how you plan to use the data.
Working with arrays
Arrays are fixed in length and store elements contiguously. They are ideal for tight loops and predictable sizes. Access is constant time and cache friendly.
int[] scores = { 10, 20, 30 };
Console.WriteLine(scores[1]);
Use array initializers for clarity. If you need to grow the data over time consider a list instead because resizing an array requires allocating a new one.
Using lists for dynamic data
A List<T> resizes as you add items. Internally .NET grows the array in chunks to keep amortized performance steady. Lists integrate smoothly with LINQ because they implement IEnumerable<T>.
var items = new List<string> { "alpha", "beta" };
items.Add("gamma");
Dictionaries for key based access
Dictionaries map keys to values using hashing. They give near constant time lookups. Keys must be unique and must provide stable equality and hashing behavior.
var ages = new Dictionary<string,int>();
ages["Ada"] = 36;
ages.TryGetValue("Ada", out var a);
Dictionaries are widely used across .NET for configuration, caching, and general lookup patterns because they combine speed with convenience.
Sets for uniqueness
A HashSet<T> stores only distinct items. Use it when you need fast membership tests or when you want to eliminate duplicates before running a query.
var seen = new HashSet<int> { 1, 2, 3 };
seen.Add(2); // ignored
Enumerables and iterators
The IEnumerable<T> interface represents a sequence you can iterate with foreach. Iterators in C# let you implement this contract with minimal boilerplate. They are the backbone of LINQ and many .NET libraries.
Understanding IEnumerable<T>
An enumerable produces values one at a time. The consumer does not need to know how the sequence is stored or computed. This separation of shape and behavior is what makes LINQ powerful.
IEnumerable<int> Range()
{
yield return 1;
yield return 2;
yield return 3;
}
foreach (var v in Range()) Console.WriteLine(v);
Iterators like this generate values lazily. The body runs only when the consumer moves through the sequence.
Building custom iterators
You can create sequences backed by any data source. A sequence might wrap a file, a network stream, or even generated values. This fits well with .NET’s streaming APIs because it avoids buffering entire data sets in memory.
public static IEnumerable<string> Lines(StreamReader reader)
{
string? line;
while ((line = reader.ReadLine()) != null)
yield return line;
}
LINQ query syntax
LINQ query syntax uses a SQL inspired style to express filtering, projection, grouping, and joining. Internally it translates to method calls on IEnumerable<T> or IQueryable<T>. The syntax is optional but can improve readability for complex queries.
Writing basic queries
A query starts with from and usually ends with select. In between you can place where, orderby, group, and other clauses.
var names = new[] { "Ada", "Brendan", "Linus" };
var result =
from n in names
where n.Length > 3
orderby n
select n.ToUpper();
The query runs when you enumerate result because LINQ uses deferred execution.
Query expressions and continuation
Query clauses can chain smoothly. You can add let clauses to introduce intermediate values; these help simplify complex expressions.
var query =
from n in names
let upper = n.ToUpper()
where upper.StartsWith("A")
select upper;
LINQ method syntax
Method syntax uses extension methods such as Where, Select, OrderBy, and GroupBy. It is precise and compact. Many developers favor method syntax for its tooling support and predictable chaining.
Filtering and projection
Filtering and shaping data are the heart of LINQ. Each call returns a new sequence that wraps the previous one. Nothing executes until enumeration begins.
var r =
names
.Where(n => n.Length > 3)
.Select(n => n.ToUpper());
The lambda expressions describe small behaviors; .NET’s implementation of LINQ composes them into streaming pipelines.
Ordering and combining calls
Ordering uses OrderBy and ThenBy. These preserve streaming behavior except during the sort itself which needs to buffer.
var sorted =
names
.OrderBy(n => n.Length)
.ThenBy(n => n);
ThenBy run only after the preceding order is established; this lets you build stable hierarchies of sort keys.
Deferred execution and streaming
LINQ sequences typically stream their values. They do not compute results until the consumer asks for them. This keeps memory usage low and helps build pipelines that operate on large inputs.
Deferred behavior in action
The following example prints only when values are pulled from the sequence. You can weave extra logging into the chain to observe this behavior.
var seq =
names
.Where(n => {
Console.WriteLine("Checking " + n);
return n.Length > 3;
});
foreach (var v in seq) Console.WriteLine("Result " + v);
Deferred execution interacts well with .NET’s streaming APIs; this lets you process large files, network responses, or generated data without buffering everything at once.
When LINQ must buffer
Operators such as OrderBy, GroupBy, and Join need to collect values before producing results. This is expected because they rely on grouping, sorting, or key matching. Be mindful of memory when using them on large sequences.
ToList or ToArray to materialize when needed.
Grouping, joins, and projections
LINQ supports relational style operations. Grouping gathers items under keys; joins correlate items across sequences; projections shape the final result. These patterns echo database queries and feel natural when working with structured data in .NET.
Grouping by key
Grouping partitions data into buckets keyed by an expression. Each group exposes the key and the elements belonging to that group.
var byLength =
names.GroupBy(n => n.Length);
foreach (var g in byLength)
{
Console.WriteLine("Length " + g.Key);
foreach (var v in g) Console.WriteLine(" " + v);
}
Joining sequences
Joins combine two sequences based on matching keys. LINQ implements hash based joins which are efficient for in memory data. They work similarly with LINQ to Objects and LINQ providers such as LINQ to SQL.
var people = new[]
{
new { Id = 1, Name = "Ada" },
new { Id = 2, Name = "Linus" }
};
var scores2 = new[]
{
new { PersonId = 1, Score = 90 },
new { PersonId = 2, Score = 88 }
};
var q =
people.Join(
scores2,
p => p.Id,
s => s.PersonId,
(p, s) => new { p.Name, s.Score });
Shaping results with projections
Projections create a new form by mapping each element through a selector. This might extract a few fields or compose a new anonymous object. Projections keep query logic tidy by isolating the final shape of the result.
var projection =
names.Select(n => new { Original = n, Upper = n.ToUpper() });
LINQ’s projection model matches the shape first approach that permeates the .NET libraries. It encourages constructing exactly the structure you need, no more and no less.