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

Разбираем C# 15 и .NET 11: union-типы, закрытые иерархии через closed, аргументы в коллекционных выражениях, extension-индексаторы и именованные break и continue.

Разработчики на 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.

Поделиться:

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *