В C# наконец появились union-типы

Разработчики на C# просили размеченные объединения (discriminated unions) почти десять лет. Обсуждение в репозитории языка годами оставалось одним из самых популярных, а команды тем временем писали свои OneOf, Result и иерархии из абстрактных record с бесконечными default: throw. В C# 15, который выходит вместе с .NET 11, союзы наконец становятся частью языка. Вместе с ними приходят закрытые иерархии, аргументы в коллекционных выражениях, extension-индексаторы и именованные break/continue. Разберём, что из этого реально пригодится в коде уже сейчас.
Union-типы: одно ключевое слово вместо библиотеки
Union описывает значение, которое может быть одним из нескольких типов. Объявляется он одной строкой, причём у вариантов не обязан быть общий предок:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);Компилятор генерирует неявные преобразования из каждого варианта, поэтому присваивание выглядит естественно. Попытка положить в Pet что-то постороннее даст ошибку компиляции:
Pet pet = new Dog("Rex");
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};Самое ценное здесь в проверке полноты. Если switch покрывает все варианты, ветка _ не нужна. Когда через полгода кто-то добавит в union четвёртый тип, компилятор выдаст предупреждение в каждом switch, который его не обрабатывает. Раньше такие ошибки всплывали в рантайме в виде InvalidOperationException из той самой ветки по умолчанию.
Внутри у union можно объявлять вспомогательные методы, и он поддерживает обобщения. Пример из блога Microsoft:
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
T single => [single],
IEnumerable<T> multiple => multiple,
null => []
};
}Что под капотом и где подвох
Компилятор превращает union в структуру с конструктором на каждый вариант и свойством Value типа object?. Отсюда главный нюанс: значимые типы внутри стандартного union упаковываются. Для бизнес-логики это несущественно, а в горячих путях, где считают аллокации, лучше написать собственный тип.
Для этого есть атрибут [Union]. Компилятор признаёт союзом любой класс или структуру с этим атрибутом, если у типа есть публичные конструкторы с одним параметром (они и задают варианты) и публичное свойство Value. Если добавить HasValue и методы TryGetValue, сопоставление с образцом будет работать без упаковки. Так авторы библиотек вроде OneOf смогут встроиться в языковую поддержку, сохранив производительность.
Ещё одна деталь: у default-значения union свойство Value равно null, поэтому для nullable-вариантов switch потребует ветку null.
Закрытые иерархии через closed
Если вам ближе классическое наследование, C# 15 предлагает модификатор closed. Он запрещает наследоваться от класса за пределами сборки, где тот объявлен, и компилятор заранее знает полный список прямых наследников:
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
};Предупреждения о неполном switch здесь нет, потому что все наследники перечислены. Закрытый класс неявно абстрактный, его нельзя совмещать с sealed, static или явным abstract. Полнота проверяется только на один уровень: если промежуточный наследник тоже должен быть закрытым, его нужно пометить closed отдельно.
Аргументы в коллекционных выражениях
Коллекционные выражения из C# 12 были удобны, пока не требовалось передать что-то в конструктор. Теперь первым элементом можно указать with(...):
string[] values = ["one", "two", "three"];
List<string> names = [with(capacity: values.Length * 2), .. values];
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
// в set один элементЁмкость списка и компаратор словаря были самыми частыми причинами возвращаться к старому синтаксису с new. Теперь эта причина исчезла.
Extension-индексаторы
Блоки extension из C# 14 научились объявлять индексаторы. Поскольку индексатор относится к экземпляру, у блока должен быть именованный параметр-получатель:
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
IEnumerable<int> numbers = Enumerable.Range(1, 10);
int third = numbers[2];Пользоваться этим стоит с головой: индексатор поверх ElementAt на ленивой последовательности каждый раз проходит её с начала. Зато для собственных обёрток и DSL получается очень чистый API.
Именованные break и continue
Выход из вложенных циклов больше не требует булевых флагов или goto. Достаточно поставить метку на внешний цикл:
outer: for (int row = 0; row < grid.Height; row++)
{
for (int column = 0; column < grid.Width; column++)
{
if (grid[row, column].IsBlocked) continue outer;
if (grid[row, column].IsGoal) break outer;
}
}Без метки операторы ведут себя как раньше. Анализатор IDE0410 подскажет места в коде, где старые конструкции можно заменить новыми.
Статические члены интерфейсов и безопасность памяти
Невиртуальные статические члены интерфейсов теперь работают и на старых рантаймах без поддержки реализаций по умолчанию. Это полезно авторам библиотек, которые таргетят .NET Framework или netstandard2.0.
Отдельно в режиме preview стартует многолетняя переработка правил unsafe. Объявление указателей, оператор &, fixed и sizeof больше не требуют небезопасного контекста, он нужен только для реального доступа к памяти: разыменования, p->member, p[i] и вызова указателей на функции. Появилась форма unsafe(expression) для одного выражения и ключевое слово safe для extern-членов. Включается это через <LangVersion>preview</LangVersion> и флаг updated-memory-safety-rules, так что в продакшен пока рано.
Как попробовать
C# 15 становится версией языка по умолчанию для проектов с net11.0 в SDK .NET 11. Все возможности, кроме правил безопасности памяти, не требуют флага preview. Достаточно поставить свежий SDK и поменять целевой фреймворк:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
</PropertyGroup>Если вы держите в проекте OneOf или самописный Result, union-типы стоит пощупать в первую очередь. Начните с одного доменного типа, например результата операции оплаты, и посмотрите, сколько веток default: throw удастся удалить.
Подробности есть в официальном обзоре What’s new in C# 15 и в статье команды .NET Explore union types in C# 15.




