C# has grown a lot of features, and the ones labelled “advanced” are mostly just shortcuts that remove boilerplate you would otherwise write by hand. This guide covers eight of them — LINQ, generics, async and await, pattern matching, records, nullable reference types, extension methods and tuples — with a before-and-after for each so you can see what problem it solves.
LINQ: querying collections#
The loop version, which every C# codebase used to be full of:
var result = new List<string>();
foreach (var person in people)
{
if (person.Age >= 18)
{
result.Add(person.Name.ToUpper());
}
}
result.Sort();
The same thing in LINQ:
using System.Linq;
var result = people
.Where(p => p.Age >= 18)
.Select(p => p.Name.ToUpper())
.OrderBy(name => name)
.ToList();
The methods you will use nearly every day:
numbers.Where(n => n > 10); // filter
numbers.Select(n => n * 2); // transform
numbers.OrderBy(n => n); // sort
numbers.First(); // throws if empty
numbers.FirstOrDefault(); // returns default if empty
numbers.Any(n => n < 0); // is there at least one?
numbers.All(n => n < 100); // are they all?
numbers.Sum(); numbers.Count(); // aggregate
people.GroupBy(p => p.City); // group
Generics: one method, many types#
Without generics you either write the method once per type, or you use object and lose all type safety:
// Works for anything, checked at compile time
public static T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
{
T best = items.First();
foreach (T item in items)
{
if (item.CompareTo(best) > 0) best = item;
}
return best;
}
int n = Largest(new[] { 3, 9, 2 }); // 9
string s = Largest(new[] { "pear", "fig" }); // "pear"
The where T : IComparable<T> part is a constraint. It tells the compiler that whatever T turns out to be, it can be compared — which is what makes CompareTo legal inside the method.
async and await#
An async method can pause at an await and give the thread back until the awaited work finishes.
public async Task<string> GetTitleAsync(string url)
{
using var client = new HttpClient();
string html = await client.GetStringAsync(url); // thread is free here
return html.Length + " characters";
}
// calling it
string result = await GetTitleAsync("https://example.com");
Three rules cover most of it: an async method returns Task or Task<T>, you can only await inside an async method, and the method name conventionally ends in Async.
Pattern matching and switch expressions#
// Type pattern
if (shape is Circle c && c.Radius > 10)
{
Console.WriteLine(c.Radius);
}
// Switch expression - an expression, so it returns a value
string Describe(object value) => value switch
{
null => "nothing",
int n when n < 0 => "negative number",
int n => $"the number {n}",
string { Length: 0 } => "empty text",
string text => $"text of length {text.Length}",
_ => "something else",
};
The _ is the catch-all. Order matters: the first matching arm wins, so put the specific cases before the general ones.
Records: value objects without the boilerplate#
public record Person(string Name, int Age);
var a = new Person("Ana", 34);
var b = new Person("Ana", 34);
Console.WriteLine(a == b); // True - compares values, not references
Console.WriteLine(a); // Person { Name = Ana, Age = 34 }
var older = a with { Age = 35 }; // copy with one change
That single line replaces a constructor, two properties, Equals, GetHashCode and ToString. Use a record when the object is defined by its data. Use a class when it has identity or changes over time.
Nullable reference types#
#nullable enable
string name = null; // warning: cannot be null
string? maybe = null; // fine, explicitly nullable
int length = maybe.Length; // warning: possible null
int safe = maybe?.Length ?? 0; // no warning
This does not change what runs — it changes what the compiler warns about. Turning it on in an existing project produces a lot of warnings at first, and working through them tends to find genuine bugs.
Extension methods#
public static class StringExtensions
{
public static string Truncate(this string text, int max)
{
if (string.IsNullOrEmpty(text) || text.Length <= max) return text;
return text.Substring(0, max) + "...";
}
}
// now every string has it
"a rather long sentence".Truncate(10); // "a rather l..."
The this keyword on the first parameter is the whole trick. The method must live in a static class. This is exactly how LINQ is implemented — Where and Select are extension methods on IEnumerable<T>.
Tuples and deconstruction#
public static (int Min, int Max) Range(int[] numbers)
{
return (numbers.Min(), numbers.Max());
}
var (low, high) = Range(new[] { 4, 9, 1 });
Console.WriteLine($"{low} to {high}"); // 1 to 9
Useful for returning two related values without inventing a class for it. If the pair starts being passed around widely, that is the signal to promote it to a record.
Questions people ask#
Is LINQ slower than a foreach loop?
Slightly, because of the delegate calls and the iterator machinery. For collections of a few thousand items the difference is invisible. In a tight inner loop measured in millions of iterations, a plain loop can be worth it — measure before you assume.
When should I use a record instead of a class?
Use a record when two instances with the same values should be considered equal, and when the object does not change after creation. Use a class when the object has an identity that outlives its values, such as anything backed by a database row.
Does async make my code run in parallel?
Not by itself. Async is about not blocking while waiting for something external. For real parallelism you would start several tasks and await them together with Task.WhenAll.
Do I have to use var?
No. Use var when the type is obvious from the right-hand side and write it out when it is not. Consistency within a codebase matters more than which rule you pick.
Where to go next#
- What is object-oriented programming? — the model C# is built on.
- What is an algorithm? — useful background for the LINQ section.
- Which programming language should I learn? — where C# fits.