Modern C# 12 to 14 features and performance
Primary constructors, collection expressions, spans, AOT and trimming, and measuring with BenchmarkDotNet before optimising.
Language features worth adopting
// primary constructors: parameters in scope for the whole type
public sealed class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
public async Task<Order?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("find {Id}", id);
return await repository.FindAsync(id, ct);
}
}
public sealed class Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount; // captured into a property
public string Currency { get; } = currency;
}
// collection expressions and spreads
int[] a = [1, 2, 3];
List<int> b = [0, .. a, 4];
ReadOnlySpan<char> s = ['a', 'b', 'c'];
// record with a positional body, and with-expressions for copies
public sealed record Line(string Sku, int Quantity, decimal UnitPrice)
{
public decimal Total => Quantity * UnitPrice;
}
var line = new Line("ABC-1", 2, 9.99m);
var bumped = line with { Quantity = 3 }; // new instance, original untouched
// pattern matching with list and property patterns
static string Describe(Line l) => l switch
{
{ Quantity: <= 0 } => "invalid",
{ UnitPrice: > 1000m } => "needs approval",
{ Sku.Length: > 16 } => "long sku",
_ => "ok",
};| Feature | Since | Replaces |
|---|---|---|
| Primary constructors | C# 12 | Constructor plus field assignments |
| Collection expressions | C# 12 | new List<int> { ... } |
params collections | C# 13 | params T[] only |
ref readonly members | C# 12 | Defensive copies of large structs |
| Inline arrays | C# 12 | Fixed-size buffers and stackalloc workarounds |
| Extension members | C# 14 | Extension methods plus static extensions |
field keyword | C# 14 | An explicit backing field in a property |
A primary-constructor parameter is captured into a hidden field only if it is used in a member body. If it is only used to initialise another member, no field is created — which is why a primary constructor adds no cost when used well, and a surprising hidden field when used carelessly.
Span-based APIs and allocation
using System.Buffers;
using System.Runtime.InteropServices; // CollectionsMarshal
// parse without allocating a substring
public static bool TryParseVersion(ReadOnlySpan<char> text, out int major, out int minor)
{
major = minor = 0;
int dot = text.IndexOf('.');
if (dot < 0) return false;
return int.TryParse(text[..dot], out major) && int.TryParse(text[(dot + 1)..], out minor);
}
// a struct that is small enough to pass by value without copying cost
public readonly record struct Point(double X, double Y);
// ref readonly return: hand out a large struct without a defensive copy
public sealed class Catalog
{
private readonly Dictionary<string, Product> _products = new();
public ref readonly Product Get(string sku) => ref CollectionsMarshal.GetValueRefOrNullRef(_products, sku);
}
public readonly record struct Product(string Sku, string Name, decimal Price);
// defer allocation with an iterator over a span
public static IEnumerable<int> Split(ReadOnlySpan<int> values)
{
foreach (int v in values)
if (v != 0) yield return v;
}- Avoid
Substringin a parsing loop; slice with aReadOnlySpan<char>range instead, which allocates nothing. - Structs larger than roughly 16 to 24 bytes cost more to copy than to reference. Pass them with
inor returnref readonly. - Boxing hides in unexpected places: a struct used as
object, an interface variable, or a non-generic collection. Each one allocates. - String concatenation in a loop builds a garbage object per iteration; use
StringBuilder,string.Createorstring.Join. - Closures over loop variables that outlive the method allocate a display class; hoist the state out when it is on a hot path.
Trimming, AOT and measuring
<!-- publish trim-safe, AOT-friendly output -->
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<StackTraceSupport>true</StackTraceSupport>
</PropertyGroup>using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class StringBench
{
private readonly string _line = "order 42 shipped";
[Benchmark(Baseline = true)]
public int SubstringAndParse() => int.Parse(_line.Substring(6, 2));
[Benchmark]
public int SpanParse() => int.Parse(_line.AsSpan(6, 2));
}
// BenchmarkRunner.Run<StringBench>(); -> reports mean, allocations per operation⚠️
Measure with a release build, the same runtime version as production, and a warm-up. A
Stopwatch around a first call measures the JIT and the cache warming, not the algorithm — and reflection-based serialisation is the most common thing that breaks under trimming.FAQ
Is reflection unusable with AOT?
It works where the trimmer can see the target, which in practice means annotating it. The general answer is to move to source generation for serialisation, configuration binding and logging, all of which now support it.
When is a struct faster than a class?
When it is small, short-lived and not boxed. Arrays of structs are contiguous and cache friendly; a large struct copied through several method calls is slower than a reference.
Related
Collections, generics and the standard library Testing with xUnit, mocking and integration tests
Last refreshed 2026-09-18.