Типы объединений C#: наконец-то появляются размеченные объединения
Если вы пишете на C# какое-то значительное время, велика вероятность, что вы уперлись в стену, пытаясь смоделировать что-то, что может быть «одной из нескольких вещей». Возможно, вам нужен метод, возвращающий либо успешное значение, либо ошибку. Возможно, вы создавали платежную систему, которая обрабатывает кредитные карты, банковские переводы и цифровые кошельки — каждый с совершенно разными данными. Или, может быть, вы просто посмотрели на F# или Rust и подумали: «Почему я не могу этого сделать на C#?»
Ожидание почти закончилось. Дискриминированные объединения приходят в C#.
Это была одна из самых востребованных функций языка на протяжении многих лет, а обсуждения в сообществе начались еще в 2017 году и раньше. Группа разработчиков языка C# работала над предложением, которое вводит ключевое слово union для определения иерархий закрытого типа с исчерпывающим сопоставлением шаблонов. В этом посте я хочу рассказать вам, что такое дискриминируемые объединения, почему они так важны, как мы до сих пор их подделывали и как на самом деле выглядит предлагаемый синтаксис — с реальными примерами кода для каждого.
Небольшое примечание, прежде чем мы углубимся: на момент написания этой статьи функция объединения типов все еще находится на стадии предложения и предварительного просмотра. Синтаксис и поведение, которые я описываю здесь, основаны на последних общедоступных документах по проектированию и обсуждениях группы разработчиков языка C#. Все может измениться до финального релиза. Я буду четко объяснять, что подтверждено, а что еще обсуждается.
Что такое дискриминируемые профсоюзы?
По своей сути дискриминируемое объединение (иногда называемое «теговым объединением» или «типом суммы») — это тип, который может содержать одно из фиксированного набора возможных значений, причем каждый вариант может нести разные данные. «Дискриминируемая» часть означает, что среда выполнения всегда знает, какой это вариант — существует тег, который его идентифицирует.
Думайте об этом как о enum, но каждый участник может нести свою собственную полезную нагрузку данных.
Если вы использовали другие языки, вы, вероятно, видели эту концепцию раньше:
В F# профсоюзы дискриминируются с самого первого дня:
[[[ТОК_2]]]
Rust использует enum для той же идеи:
[[[ТОК_4]]]
TypeScript достигает чего-то похожего с теговыми объединениями:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
Во всех этих случаях компилятор знает все возможные варианты и может заставить вас обработать их все. Это суперсила: полная проверка. Если вы добавите новый вариант, компилятор везде сообщит вам, что вы забыли его обработать.
В C# никогда не было первоклассного способа выразить это. До настоящего времени.
Как мы моделируем профсоюзы сегодня
За прошедшие годы сообщество C# придумало несколько обходных путей, каждый из которых имел свои недостатки. Позвольте мне рассказать о наиболее распространенных подходах.
Абстрактные записи с наследованием
Самый идиоматический обходной путь в современном C# — использование абстрактных записей с запечатанными производными типами:
[[[ТОК_6]]]
Это работает достаточно хорошо. Вы получаете неизменяемость, равенство значений и можете использовать сопоставление с образцом:
[[[ТОК_7]]]Но есть существенные недостатки. Компилятор не знает, что иерархия закрыта, поэтому вам всегда нужен этот рычаг сброса _, иначе вы получите предупреждение. Если вы добавите новый вариант, компилятор не сообщит вам обо всех местах, где вы забыли его обработать — сброс молча проглотит его. Это полностью противоречит цели.
Библиотека OneOf
Другой популярный подход — пакет NuGet OneOf:
[[[ТОК_10]]]
OneOf обеспечивает проверку полноты во время компиляции с помощью параметров универсального типа, и это здорово. Но он полагается на позиционное сопоставление (первого типа, второго типа и т. д.), общие сигнатуры быстро становятся громоздкими и не интегрируются с сопоставлением шаблонов языка. Это умный хак, но это всё равно хак.
Ручное перечисление + шаблон данных
Некоторые разработчики идут по классическому пути, используя тег enum и контейнер данных:
[[[ТОК_11]]]
Это хрупко. Ничто не мешает вам установить для Type значение CreditCard, кроме заполнения свойства BankTransfer. Компилятор не сможет вам помочь, и вы получите повсюду ошибки времени выполнения и нулевые проверки. Это «строчно типизированный» подход к моделированию типов, и он не масштабируется.
Все эти подходы имеют общую фундаментальную проблему: они борются с языком, а не работают с ним. Компилятор не может рассуждать о закрытом наборе возможностей, поэтому вы теряете самое ценное свойство дискриминируемых объединений — исчерпывающую проверку.
Предложение C#: ключевое слово union
В предложении группы языка C# представлено выделенное ключевое слово union, которое определяет закрытый набор именованных членов, каждый из которых может необязательно нести данные. Вот основной синтаксис, как он был предложен:
union Shape
{
Circle(double Radius),
Rectangle(double Width, double Height),
Triangle(double Base, double Height)
}
Вот и все. Четко, лаконично и сразу читается. Каждый член внутри союза определяет отдельный вариант со своими собственными данными. Компилятор знает, что Shape может быть только одним из этих трех объектов.
Под капотом компилятор генерирует иерархию запечатанных типов — аналогично тому, что вы пишете вручную с абстрактными записями, но с полным пониманием компилятором закрытой природы типа. Это означает, что компилятор может обеспечить полноту сопоставления с образцом, что является ключевым преимуществом.
Члены, имеющие только значение
Членам профсоюза не нужно переносить данные. Вы можете смешивать элементы, переносящие данные, с простыми элементами-значениями:
union Option<T>
{
Some(T Value),
None
}
Это классический тип Option/Maybe, который функциональные программисты просили в C# в течение многих лет. None не содержит данных — это просто тег.
Общие союзы
Как видно из примера Option<T> выше, объединения поддерживают дженерики. Вот более сложный пример:
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
Это открывает целый стиль обработки ошибок, который не основан на исключениях для ожидаемых случаев сбоя — то, что уже много лет является стандартной практикой в Rust и функциональных языках.
Объединения с методами
Предложение также позволяет объединениям иметь методы, вычисляемые свойства и реализовывать интерфейсы, как и любой другой тип:```csharp union Shape { Circle(double Radius), Rectangle(double Width, double Height), Triangle(double Base, double Height);
public double Area => this switch
{
Circle(var r) => Math.PI * r * r,
Rectangle(var w, var h) => w * h,
Triangle(var b, var h) => 0.5 * b * h
};
public double Perimeter => this switch
{
Circle(var r) => 2 * Math.PI * r,
Rectangle(var w, var h) => 2 * (w + h),
Triangle(var b, var h) => b + h + Math.Sqrt(b * b + h * h)
};
}
Обратите внимание, что выражение `switch` внутри `Area` и `Perimeter` не требует руки по умолчанию. Компилятор знает, что объединение является исчерпывающим — существует только три варианта, и все три обрабатываются. Если позже вы добавите четвертый вариант, компилятор пометит каждый `switch` , который его не обрабатывает.
## Интеграция сопоставления с образцом
Сопоставление шаблонов развивается в C# начиная с версии 7.0, и типы объединения созданы для того, чтобы стать первоклассными членами этой системы.
### Исчерпывающие выражения переключения
Самая эффективная функция — исчерпывающая проверка переключателей. При использовании объединений компилятор **знает** все возможные случаи:
```csharp
string Describe(Shape shape) => shape switch
{
Circle(var r) => $"A circle with radius {r}",
Rectangle(var w, var h) => $"A {w}x{h} rectangle",
Triangle(var b, var h) => $"A triangle with base {b} and height {h}"
};
Нет рычага для сброса. Нет _ => throw new NotImplementedException(). Если вы забудете регистр, компилятор выдаст ошибку, а не предупреждение. Это фундаментальное улучшение безопасности.
Сопоставление вложенных шаблонов
Объединения естественным образом составляются с помощью вложенных шаблонов:
union Expr
{
Literal(double Value),
Add(Expr Left, Expr Right),
Multiply(Expr Left, Expr Right),
Negate(Expr Inner)
}
double Evaluate(Expr expr) => expr switch
{
Literal(var v) => v,
Add(var left, var right) => Evaluate(left) + Evaluate(right),
Multiply(var left, var right) => Evaluate(left) * Evaluate(right),
Negate(var inner) => -Evaluate(inner)
};
Этот вид рекурсивной структуры данных чрезвычайно распространен в компиляторах, интерпретаторах, механизмах правил и математическом моделировании. Сегодня в C# вам понадобится глубокая иерархия классов и шаблон посетителя. С объединениями код значительно упрощается.
Охранные положения
Сопоставление шаблонов с объединениями поддерживает защиту when так, как и следовало ожидать:
string Classify(Shape shape) => shape switch
{
Circle(var r) when r > 100 => "Large circle",
Circle(var r) when r > 10 => "Medium circle",
Circle(_) => "Small circle",
Rectangle(var w, var h) when w == h => "Square",
Rectangle _ => "Rectangle",
Triangle _ => "Triangle"
};
Практические примеры
Позвольте мне рассказать о некоторых реальных сценариях, в которых типы объединения значительно улучшают код.
Шаблон результата: замена исключений на ожидаемые ошибки
Одним из наиболее распространенных шаблонов в разработке современных приложений является представление операций, которые могут быть успешными или неудачными, без использования исключений для потока управления. Исключения должны быть исключительными — например, сбои сети или нехватка памяти. Ошибка проверки или результат «не найден» — это ожидаемый результат, а не исключение.
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
union OrderError
{
NotFound(Guid OrderId),
InsufficientStock(string ProductId, int Requested, int Available),
PaymentDeclined(string Reason),
ValidationFailed(IReadOnlyList<string> Errors)
}
Result<Order, OrderError> ProcessOrder(OrderRequest request)
{
if (!Validate(request, out var errors))
return new ValidationFailed(errors);
var product = catalog.Find(request.ProductId);
if (product is null)
return new NotFound(request.OrderId);
if (product.Stock < request.Quantity)
return new InsufficientStock(request.ProductId, request.Quantity, product.Stock);
var paymentResult = paymentGateway.Charge(request.Payment);
if (!paymentResult.Success)
return new PaymentDeclined(paymentResult.Message);
var order = CreateOrder(request, product);
return new Ok(order);
}
Затем вызывающая сторона вынуждена обрабатывать все возможные результаты:
var result = ProcessOrder(request);
var response = result switch
{
Ok(var order) => Results.Created($"/orders/{order.Id}", order),
Error(NotFound(var id)) => Results.NotFound($"Order {id} not found"),
Error(InsufficientStock(var pid, var req, var avail)) =>
Results.Conflict($"Product {pid}: requested {req}, only {avail} available"),
Error(PaymentDeclined(var reason)) =>
Results.UnprocessableEntity($"Payment declined: {reason}"),
Error(ValidationFailed(var errors)) =>
Results.BadRequest(new { Errors = errors })
};
Никаких блоков try-catch, забытых типов исключений и сюрпризов во время выполнения. Каждый режим отказа виден в сигнатуре типа и применяется компилятором. Это значительное улучшение надежности API.
Моделирование домена: типы платежей
Вот реальный пример моделирования домена — обработка различных способов оплаты в системе электронной коммерции:
union PaymentMethod
{
CreditCard(string CardNumber, string Expiry, string Cvv),
BankTransfer(string Iban, string Bic),
DigitalWallet(string Provider, string Token),
CashOnDelivery
}
decimal CalculateProcessingFee(PaymentMethod method, decimal amount) => method switch
{
CreditCard _ => amount * 0.029m + 0.30m,
BankTransfer _ => 1.50m,
DigitalWallet(provider: "PayPal", _) => amount * 0.034m + 0.35m,
DigitalWallet _ => amount * 0.025m,
CashOnDelivery => 4.99m
};
string FormatForReceipt(PaymentMethod method) => method switch
{
CreditCard(var num, _, _) => $"Credit Card ending in {num[^4..]}",
BankTransfer(var iban, _) => $"Bank Transfer ({iban[..4]}****)",
DigitalWallet(var provider, _) => $"Digital Wallet ({provider})",
CashOnDelivery => "Cash on Delivery"
};
Сравните это с текущим подходом, в котором у вас есть интерфейс или абстрактный класс с пятью различными реализациями, распределенными по пяти файлам, возможно, с шаблоном посетителя сверху. Подход объединения сохраняет определение данных и операции вместе, читабельными и полностью проверяемыми.
Государственные машины
Конечные автоматы присутствуют повсюду в программном обеспечении — обработка заказов, механизмы рабочих процессов, управление соединениями, состояние пользовательского интерфейса. Объединения делают их явными и безопасными:
union ConnectionState
{
Disconnected,
Connecting(string Host, int Port, DateTime StartedAt),
Connected(string Host, int Port, TcpClient Client),
Reconnecting(string Host, int Port, int Attempt, TimeSpan Delay),
Failed(string Host, Exception Error)
}
ConnectionState HandleEvent(ConnectionState state, ConnectionEvent evt) => (state, evt) switch
{
(Disconnected, Connect(var host, var port)) =>
new Connecting(host, port, DateTime.UtcNow),
(Connecting(var h, var p, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Connecting(var h, var p, _), ConnectionFailed(var ex)) =>
new Reconnecting(h, p, Attempt: 1, Delay: TimeSpan.FromSeconds(1)),
(Reconnecting(var h, var p, var attempt, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(var ex)) when attempt >= 5 =>
new Failed(h, ex),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(_)) =>
new Reconnecting(h, p, attempt + 1, TimeSpan.FromSeconds(Math.Pow(2, attempt))),
(Connected(_, _, var client), Disconnect) =>
new Disconnected,
_ => state // Ignore events that don't apply to current state
};
Каждое состояние несет в себе именно те данные, которые относятся к этому состоянию. Вы не можете случайно получить доступ к TcpClient в состоянии Connecting, поскольку он не существует в этом варианте. Система типов обеспечивает соблюдение инвариантов конечного автомата.
Вопросы сериализации и взаимодействияОдин из практических вопросов, который сразу же возникает в связи с типами объединения: как их сериализовать? Если вы создаете API или храните данные, для правильной работы вам необходима сериализация JSON.
Команда разработчиков обсуждала встроенную поддержку System.Text.Json для типов объединения. Ожидаемый подход предполагает сериализацию со свойством дискриминатора:
{
"$type": "Circle",
"radius": 5.0
}
{
"$type": "Rectangle",
"width": 10.0,
"height": 20.0
}
Это соответствует существующей поддержке полиморфной сериализации, представленной в .NET 7 с атрибутами JsonDerivedType. Ожидается, что объединения будут работать с System.Text.Json «из коробки», используя имя варианта в качестве дискриминатора типа по умолчанию.
Для Entity Framework Core вероятным подходом является сохранение значений объединения с использованием столбца дискриминатора — аналогично тому, как уже работает сопоставление наследования «таблица на иерархию» (TPH). Точная интеграция EF Core все еще разрабатывается, но инфраструктура для обработки иерархий закрытого типа уже существует.
Стоит отметить, что взаимодействие с другими языками .NET должно быть плавным, поскольку объединения будут компилироваться в стандартные иерархии классов IL. Код F#, использующий объединение C#, будет рассматривать его как стандартную иерархию типов, и наоборот.
Как попробовать
На момент написания объединенные типы доступны в качестве предварительной функции в последних предварительных версиях .NET SDK. Чтобы поэкспериментировать с предложенным синтаксисом, вам необходимо:
- Установите последнюю версию SDK предварительной версии .NET.
- Включите языковую версию предварительного просмотра в файле проекта:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
Имейте в виду, что функции предварительной версии могут быть изменены. Синтаксис, поведение и диагностика компилятора могут значительно измениться до окончательного выпуска. Не отправляйте рабочий код, полагаясь на функции языка предварительной версии, но обязательно экспериментируйте с ними и оставляйте отзывы. Команда C# активно следит за обсуждениями репозитория csharplan.
Если вы хотите следить за ходом рассмотрения предложения, обратите внимание на следующие ключевые места:
- Репозиторий dotnet/csharplan для обсуждений языкового дизайна. — Репозиторий dotnet/roslyn для информации о ходе реализации компилятора.
- Блог .NET для официальных объявлений.
Сравнение с существующими подходами
Позвольте мне провести быстрое сравнение, чтобы вы могли увидеть, как складываются различные подходы:
| Особенность | Абстрактные записи | OneOf<T1,T2> | Перечисление + Данные | Типы союзов |
|---|---|---|---|---|
| Проверка полноты | ❌ Нет | ✅ Да | ❌ Нет | ✅ Да |
| Сопоставление с образцом | ✅ Да | ❌ Ограниченная | ❌ Руководство | ✅ Родной |
| Закрытие, принудительное компилятором | ❌ Нет | ✅ Да | ❌ Нет | ✅ Да |
| Данные по варианту | ✅ Да | ✅ Да | ⚠️ Хрупкий | ✅ Да |
| Читабельность | ⚠️ Подробный | ⚠️ Позиционный | ❌ Бедный | ✅ Отлично |
| Сериализация | ✅ Руководство | ⚠️Комплекс | ✅ Руководство | ✅ Встроенный |
| Шаблон | ⚠️ Умеренный | ✅ Низкий | ⚠️ Высокий | ✅ Минимальный |
| Нет внешней зависимости | ✅ Да | ❌ NuGet | ✅ Да | ✅ Да |
Что это значит для экосистемы .NET
Введение дискриминационных профсоюзов отразится на всей экосистеме .NET. Вот что я ожидаю увидеть:
Дизайн библиотеки будет улучшен. API, которые в настоящее время возвращают null для обозначения «не найдено» или выдают исключения в случае сбоев проверки, смогут вместо этого возвращать типы Result<T, E> . Это делает режимы сбоя явными в сигнатуре типа — вы можете увидеть, что может пойти не так, взглянув на сигнатуру метода, а не читая документацию или исходный код.
Моделирование предметной области становится более выразительным. Разрыв между предметной областью задачи и представлением кода резко сокращается. Когда ваш эксперт в предметной области говорит: «Платеж может быть кредитной картой, банковским переводом или наложенным платежом», вы можете смоделировать это непосредственно как объединение, а не переводить его в иерархию наследования.
Идеи F# становятся доступными разработчикам C#. Многие разработчики C# восхищались системой типов F# на расстоянии, но не смогли внедрить F# в своих организациях. Типы объединения привносят в C# одну из самых мощных функций F#, что является победой для всей экосистемы .NET.
Меньше ошибок во время выполнения. Одна только проверка полноты позволит предотвратить целые категории ошибок. Каждый раз, когда вы добавляете в объединение новый вариант, компилятор покажет вам все места в кодовой базе, требующие обновления. Больше никаких забытых случаев switch и NotImplementedException в ветках по умолчанию, которые появляются только в рабочей среде.
Заключение
Дискриминированные профсоюзы уже почти десять лет находятся на вершине списка пожеланий сообщества C#, и не без причины. Они устраняют фундаментальный пробел в системе типов — возможность моделировать данные, которые могут быть «одной из нескольких вещей» с безопасностью, обеспечиваемой компилятором.
Предлагаемое ключевое слово union обеспечивает чистый, лаконичный синтаксис, который глубоко интегрируется с сопоставлением шаблонов C#, работает с универсальными шаблонами, поддерживает методы и интерфейсы и обеспечивает исчерпывающую проверку, которая выявляет ошибки во время компиляции, а не во время выполнения.
Создаете ли вы модели предметной области, разрабатываете API с явными типами ошибок, реализуете конечные автоматы или просто пытаетесь заменить неудобные иерархии наследования чем-то более естественным — типы объединения изменят то, как вы пишете на C#.
Мы долго этого ждали. Синтаксис элегантен, интеграция с существующими функциями языка продумана, а практическое влияние будет ощущаться во всей экосистеме .NET.
Следите за предварительными версиями, экспериментируйте с этой функцией и оставляйте отзывы команде разработчиков языка. Это одна из тех функций, воспользовавшись которой, вы удивитесь, как вы раньше жили без нее.