C# 11–12 and .NET 7–8: Generic Math, Frozen and Collection Expressions
The generic sum you couldn't write in C# 10, generic math with static abstract members and INumber<T>; FrozenDictionary and FrozenSet, built once and read many times, measured with BenchmarkDotNet; square-bracket collection expressions, spread and CollectionBuilder.
Version note: This post covers the generics and collection features that came with C# 11–12 and .NET 7–8; the generic math examples target C# 11 / .NET 7, the Frozen and collection expression examples target C# 12 / .NET 8, and all were built and run with the .NET 10 SDK.
Guess First
With C# 12 we can write collections with square brackets. The program below compiles. Which runtime types do you think the last three lines print? In particular, which class is behind the expressions assigned to interfaces such as IEnumerable<int> and IList<int>?
int[] prices = [40, 45, 50];
List<int> stocks = [.. prices, 7];
IEnumerable<int> none = [];
IEnumerable<int> some = [1, 2, 3];
IList<int> editable = [1, 2, 3];
Console.WriteLine(stocks.Count);
Console.WriteLine(none.GetType().Name);
Console.WriteLine(some.GetType().Name);
Console.WriteLine(editable.GetType().Name);
Collection Expressions (C# 12)
Output:
4
Int32[]
<>z__ReadOnlyArray`1
List`1
The same [...] syntax produces a different collection depending on the target type. If the target is an array, you get an array; if it is a List<int>, you get a list. When the target is an interface, the compiler makes the choice: for an empty IEnumerable<int> it uses the shared empty array, for a non-empty IEnumerable<int> it puts in <>z__ReadOnlyArray, a type the compiler generates that cannot be changed from outside, and for IList<int>, a mutable contract, it gives you a List<int>. You don't need to think about this while writing code, but one of its consequences shows up in the pitfalls section.
In C# 10 and .NET 6 we built collections with a separate syntax for each type: new[] { 3, 0, 5 } for an array, a collection initializer such as new() { "Clean Code", "Refactoring" } for a list, ImmutableArray.Create(...) for an immutable array, and new List<string>(titles) { "Domain-Driven Design" } or AddRange to combine two collections. C# 12 reduces all of them to a single syntax:
using System.Collections.Immutable;
int[] stocks = [3, 0, 5];
List<string> titles = ["Clean Code", "Refactoring"];
List<string> reading = [.. titles, "Domain-Driven Design"];
ImmutableArray<string> shelf = ["The Pragmatic Programmer", .. titles];
Span<int> buffer = [1, 2, 3];
string[] none = [];
Console.WriteLine(string.Join(", ", reading));
Console.WriteLine($"{shelf.Length} on the shelf, first: {shelf[0]}");
Console.WriteLine($"{stocks.Length + buffer.Length + none.Length} numbers");
Clean Code, Refactoring, Domain-Driven Design
3 on the shelf, first: The Pragmatic Programmer
6 numbers
The .. spread element opens up the elements of another collection right where it stands; you can put it at the start, in the middle or at the end. [] is the empty form of the target type. The expression assigned to Span<int> replaces what you wrote with stackalloc in C# 10. The reason the ImmutableArray<string> line compiles is the attribute we will see under the next heading.
When I compiled the same file with LangVersion 10, the compiler gave the same error for every pair of square brackets; here is the first line:
Program.cs(3,16): error CS8936: Feature 'collection expressions' is not available in C# 10.0. Please use language version 12.0 or greater.
CollectionBuilder for Your Own Type
CollectionBuilderAttribute, which came with .NET 8, lets your own collection type be created with square brackets too. You tell the compiler "to create this type, call this static method and pass the elements as a ReadOnlySpan<T>":
using System.Collections;
using System.Runtime.CompilerServices;
BookShelf shelf = ["Clean Code", "Refactoring", "Domain-Driven Design"];
Console.WriteLine($"{shelf.Count} books, first: {shelf.First()}");
[CollectionBuilder(typeof(BookShelf), nameof(Create))]
class BookShelf : IEnumerable<string>
{
private readonly string[] _titles;
private BookShelf(string[] titles) => _titles = titles;
public int Count => _titles.Length;
public static BookShelf Create(ReadOnlySpan<string> titles) => new(titles.ToArray());
public IEnumerator<string> GetEnumerator() => ((IEnumerable<string>)_titles).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
3 books, first: Clean Code
The compiler put the elements into a span and called BookShelf.Create. Because ImmutableArray<T> and the other immutable types are marked with this attribute in .NET 8, we can build them with square brackets as well.
Why You Couldn't Write a Generic Sum in C# 10
Early in the series we saw generic methods and constraints. If you wanted a single generic method that adds up the quantities, prices or discount rates of order lines, C# 10 would stop you:
Console.WriteLine(Sum(new[] { 3, 0, 5 }));
static T Sum<T>(IEnumerable<T> values)
{
T total = default!;
foreach (T value in values)
total += value;
return total;
}
Program.cs(7,9): error CS0019: Operator '+=' cannot be applied to operands of type 'T' and 'T'
The compiler knows nothing about T, so it cannot resolve +. C# 10 also had no constraint to express it: interfaces could describe instance methods, but operators and values such as Zero are static. So you either wrote a separate overload for each type, or you asked for the addition from outside as a Func<T, T, T>. That is why LINQ's Sum has separate overloads for int, long, float, double, decimal and their nullable forms.
Generic Math (C# 11 / .NET 7)
C# 11 made it possible to write static abstract members in interfaces. On top of that, .NET 7 built a family of interfaces in the System.Numerics namespace: INumber<T>, IAdditionOperators<TSelf, TOther, TResult>, IAdditiveIdentity<TSelf, TResult> and more. Built-in types such as int, decimal and double implement these interfaces. The same method can now be written:
using System.Globalization;
using System.Numerics;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Sum(new[] { 3, 0, 5 }));
Console.WriteLine(Sum(new[] { 37.90m, 42.50m }));
Console.WriteLine(Sum(new[] { 1.5, 2.25 }));
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (T value in values)
total += value;
return total;
}
8
80.40
3.75
Look at the T.Zero line: we access a static member not through an instance but through the type parameter itself. The where T : INumber<T> constraint guarantees that every type that comes in as T has a Zero property and a + operator, and the compiler finds the right implementation for every call.
Your Own Contract with static abstract
INumber<T> is a broad contract: it asks for comparison, multiplication, division, parsing and more. For your own type, such as a money amount, you don't need all of that. If adding is the only ability you need, you narrow the constraint to ask only for that. You can use the same mechanism in your own interfaces too:
using System.Globalization;
using System.Numerics;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Total(new[] { new Money(37.90m), new Money(42.50m) }));
Console.WriteLine(Total(new[] { 3, 0, 5 }));
Console.WriteLine(Label<EBook>());
static T Total<T>(IEnumerable<T> values)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
T total = T.AdditiveIdentity;
foreach (T value in values)
total += value;
return total;
}
static string Label<T>() where T : ICatalogItem => $"{typeof(T).Name}: {T.Category}";
interface ICatalogItem
{
static abstract string Category { get; }
}
record EBook(string Isbn, string Title) : ICatalogItem
{
public static string Category => "Digital";
}
readonly record struct Money(decimal Amount)
: IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
public static Money AdditiveIdentity => new(0m);
public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);
}
Money { Amount = 80.40 }
8
EBook: Digital
Total now works for both Money and int, because both provide the addition operator and the identity element for addition. The static abstract string Category in ICatalogItem is a contract that needs no instance: Label<EBook>() reads the category without ever creating an EBook. In C# 10 you would have done this with reflection or with a separate dictionary entry for each type.
If you compile the same file targeting net7.0 but with LangVersion 10, the APIs are there but the language feature is not. Two lines from the output:
Program.cs(23,28): error CS8703: The modifier 'abstract' is not valid for this item in C# 10.0. Please use language version '11.0' or greater.
Program.cs(36,34): error CS8706: 'Money.operator +(Money, Money)' cannot implement interface member 'IAdditionOperators<Money, Money, Money>.operator +(Money, Money)' in type 'Money' because feature 'static abstract members in interfaces' is not available in C# 10.0. Please use language version '11.0' or greater.
Frozen Collections (.NET 8)
Data that is loaded at application startup and never changes during its lifetime is very common: country codes, a category tree, a price list, allowed ISBN prefixes. In C# 10/.NET 6 you either kept it in a Dictionary and hoped nobody touched it, or used ImmutableDictionary. The second gives you immutability but pays O(log n) on reads; we saw that in the complexity table in an earlier post. .NET 8 brought a third way in the System.Collections.Frozen namespace: FrozenDictionary<TKey, TValue> and FrozenSet<T>.
using System.Collections.Frozen;
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var source = new Dictionary<string, decimal>
{
["978-0132350884"] = 37.90m,
["978-0135957059"] = 42.50m,
["978-0321125217"] = 54.00m,
};
FrozenDictionary<string, decimal> prices = source.ToFrozenDictionary();
FrozenSet<string> bestsellers = new[] { "978-0132350884", "978-0321125217" }.ToFrozenSet();
source["978-0132350884"] = 29.90m;
Console.WriteLine(prices["978-0132350884"]);
Console.WriteLine(bestsellers.Contains("978-0135957059"));
Console.WriteLine(prices.GetType().Name);
try
{
((IDictionary<string, decimal>)prices).Add("978-1617294532", 39.99m);
}
catch (NotSupportedException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
37.90
False
LengthBucketsFrozenDictionary`1
NotSupportedException: Specified method is not supported.
ToFrozenDictionary() takes a copy of the source as it is at that moment; we changed the source dictionary afterwards, but the frozen price stayed where it was. A frozen collection has no Add or SetItem that "returns the changed copy" like ImmutableDictionary does; it is built once and only read after that. Because it implements IDictionary<TKey, TValue>, the mutating methods are visible through the interface, but they throw NotSupportedException.
The real difference is at creation time. ToFrozenDictionary() looks at the keys and picks an implementation to suit them; the name LengthBucketsFrozenDictionary in the output tells you that, for this small dictionary, it organized things by key length. That analysis makes creation more expensive, and in return it makes reads faster.
Measuring: Reads and Creation
We measured looking up every key once in a price list of 1,000 ISBNs, and building that same list, with three types:
using System.Collections.Frozen;
using System.Collections.Immutable;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run([typeof(LookupBenchmarks), typeof(BuildBenchmarks)]);
public static class Catalog
{
public static KeyValuePair<string, decimal>[] Create(int count) =>
Enumerable.Range(0, count)
.Select(i => KeyValuePair.Create($"978-{i:D9}", 10m + i % 50))
.ToArray();
}
[InProcess]
[MemoryDiagnoser]
public class LookupBenchmarks
{
private string[] _keys = null!;
private Dictionary<string, decimal> _dictionary = null!;
private FrozenDictionary<string, decimal> _frozen = null!;
private ImmutableDictionary<string, decimal> _immutable = null!;
[GlobalSetup]
public void Setup()
{
KeyValuePair<string, decimal>[] items = Catalog.Create(1_000);
_keys = items.Select(item => item.Key).ToArray();
_dictionary = new Dictionary<string, decimal>(items);
_frozen = items.ToFrozenDictionary();
_immutable = items.ToImmutableDictionary();
}
[Benchmark(Baseline = true)]
public decimal Dictionary()
{
decimal sum = 0;
foreach (string key in _keys)
if (_dictionary.TryGetValue(key, out decimal price))
sum += price;
return sum;
}
[Benchmark]
public decimal Frozen()
{
decimal sum = 0;
foreach (string key in _keys)
if (_frozen.TryGetValue(key, out decimal price))
sum += price;
return sum;
}
[Benchmark]
public decimal Immutable()
{
decimal sum = 0;
foreach (string key in _keys)
if (_immutable.TryGetValue(key, out decimal price))
sum += price;
return sum;
}
}
[InProcess]
[MemoryDiagnoser]
public class BuildBenchmarks
{
private KeyValuePair<string, decimal>[] _items = null!;
[GlobalSetup]
public void Setup() => _items = Catalog.Create(1_000);
[Benchmark(Baseline = true)]
public Dictionary<string, decimal> BuildDictionary() => new(_items);
[Benchmark]
public FrozenDictionary<string, decimal> BuildFrozen() => _items.ToFrozenDictionary();
[Benchmark]
public ImmutableDictionary<string, decimal> BuildImmutable() => _items.ToImmutableDictionary();
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|----------- |----------:|----------:|----------:|------:|--------:|----------:|------------:|
| Dictionary | 9.134 us | 0.1736 us | 0.1782 us | 1.00 | 0.03 | - | NA |
| Frozen | 5.765 us | 0.0153 us | 0.0128 us | 0.63 | 0.01 | - | NA |
| Immutable | 14.058 us | 0.0638 us | 0.0498 us | 1.54 | 0.03 | - | NA |
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---------------- |----------:|----------:|----------:|------:|--------:|--------:|-------:|----------:|------------:|
| BuildDictionary | 8.746 us | 0.1286 us | 0.1203 us | 1.00 | 0.02 | 4.7455 | - | 38.91 KB | 1.00 |
| BuildFrozen | 55.791 us | 0.8955 us | 0.8376 us | 6.38 | 0.13 | 29.3579 | 5.6763 | 240.19 KB | 6.17 |
| BuildImmutable | 77.894 us | 0.5185 us | 0.4597 us | 8.91 | 0.13 | 8.5449 | 1.4648 | 70.38 KB | 1.81 |
The project targets net8.0 and ran in-process, in Release mode, on the .NET 8.0.25 runtime. System.Collections.Immutable 9.0.0 came in as a dependency of the BenchmarkDotNet package, so the Frozen and immutable types were measured from that package's implementation; with .NET 8's own copy the numbers may differ somewhat. On the read side, FrozenDictionary finished the 1,000 lookups in about two thirds of the time Dictionary took (5.77 µs against 9.13 µs), while ImmutableDictionary was 54 percent slower. On the creation side the picture flips: freezing the same 1,000 entries took 6.4 times as long as building a Dictionary and allocated 6 times as much memory.
The decision rule follows from this: use a frozen collection where you build it once and read it many times, typically for data created at application startup and held as a singleton. Freezing a dictionary that is rebuilt on every request means losing the gain to the cost of creation.
What to Know When Upgrading
These features stand on two legs, and both are needed. Collection expressions, static abstract members and spread are language features and depend on LangVersion. INumber<T>, FrozenDictionary and CollectionBuilderAttribute are library APIs and depend on TargetFramework. The SDK picks LangVersion from the target framework by default: C# 11 for net7.0, C# 12 for net8.0. Microsoft states plainly that using a language version newer than the target framework's default is not supported, so in most projects the cleanest path is to leave LangVersion out and upgrade the TFM.
Don't forget the support dates either. Support for .NET 7 ended in May 2024. .NET 8 is LTS, but its support ends on November 10, 2026. Everything in this post works unchanged on .NET 10 (LTS, supported until November 14, 2028); if you are starting a new project, that is where you should aim. We cover moving an old project forward step by step in the C# Versions series.
Common Pitfalls
Writing a collection expression with var. Square brackets have no type of their own; they take their type from the target:
var titles = ["Clean Code", "Refactoring"];
Program.cs(1,14): error CS9176: There is no target type for the collection expression.
Write the variable's type explicitly: string[] titles = [...] or List<string> titles = [...].
Casting a collection expression assigned to an interface to a concrete type. As we saw in the guessing question, a read-only interface can have a compiler-generated type behind it:
IReadOnlyList<string> titles = ["Clean Code", "Refactoring"];
try
{
var list = (List<string>)titles;
}
catch (InvalidCastException)
{
Console.WriteLine($"Not a List<string>: {titles.GetType().Name}");
}
Not a List<string>: <>z__ReadOnlyArray`1
If you need a list, make the target type List<string>; if you assigned it to an interface, stay within that interface's contract.
Keeping the constraint too broad. When you write where T : INumber<T>, your own Money type cannot use that method, because it does not implement everything INumber<T> asks for:
using System.Numerics;
Console.WriteLine(Sum(new[] { new Money(37.90m), new Money(42.50m) }));
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (T value in values)
total += value;
return total;
}
readonly record struct Money(decimal Amount);
Program.cs(3,19): error CS0315: The type 'Money' cannot be used as type parameter 'T' in the generic type or method 'Sum<T>(IEnumerable<T>)'. There is no boxing conversion from 'Money' to 'System.Numerics.INumber<Money>'.
Ask for the ability you really need: IAdditionOperators and IAdditiveIdentity are enough for adding.
Creating frozen collections often. As the measurement showed, the creation cost is several times that of a normal dictionary. For short-lived or frequently changing data, keep using Dictionary.
A Small Experiment
★☆☆ Combine two lists and one book with the line List<string> all = [.. fiction, .. technical, "Clean Code"];; also write how you would do the same in C# 10, and compare the two lines.
★★☆ Write the method static T Max<T>(IEnumerable<T> values) where T : INumber<T>. What should it do on an empty sequence? Return T.Zero, or throw an exception? Justify your decision.
★★★ Build a FrozenDictionary<string, string> that maps ISBN prefixes to publisher names, and compare it with Dictionary for 10,000 lookups in BenchmarkDotNet. How does creation time change as you raise the key count from 10 to 100,000?
In the next post we move on to C# 13–14 and .NET 9–10: the generic OrderedDictionary<TKey, TValue> that keeps insertion order, params working with collections other than arrays, alternate lookup that lets you search a dictionary without allocating a string, and the small but handy additions .NET 10 brought to collections.