Всем привет. Среди многих интересных концепций, имеющихся в F#, меня привлекли Discriminated Unions. Я задался вопросом, как их реализовать в C#, ведь в нем отсутствует поддержка (синтаксическая) типов объединений, и я решил найти способ их имитации.
Discriminated Unions - тип данных, представляющий собой размеченные объединения, каждый из которых может состоять из собственных типов данных (также именованных).
Идея в том, что мы имеем ограниченное количество вариантов выбора, и каждый вариант может состоять из своего набора данных, никак не связанных с другими, но все варинанты объединены общим подтипом.
Для создания своих Discriminated Unions будем использовать эту мысль
Реализация
"Эталоном" будет реализация на F#
type Worker =
| Developer of KnownLanguages: string seq
| Manager of MaintainedProjectsCount: int
| Tester of UnitTestsPerHour: double
Теперь реализация на C#
public abstract record Worker
{
private Worker(){ }
public record Developer(IEnumerable<string> KnownLanguages): Worker { }
public record Manager(int MaintainedProjectsCount) : Worker;
public record Tester(double UnitTestsPerHour) : Worker;
}
Данная реализация подходит, под описанные выше критерии:
Ограниченный набор вариантов - все варанты выбора - внутри другого класса с приватным конструктором.
Каждый вариант состоит из своего набора данных - каждый вариант это отдельный класс.
Объединенные общим названием/подтипом - все наследуют базовый абстрактный класс.
В данной реализации я использовал record, т.к. они позволяют написать меньше кода и по поведению очень похожи на Discriminated Unions.
Использование
Функция на F#, использующая наш тип:
let getWorkerInfo (worker: Worker) =
match worker with
| Developer knownLanguages ->
$"Known languages: %s{String.Join(',', knownLanguages)}"
| Manager maintainedProjectsCount ->
$"Currently maintained projects count %i{maintainedProjectsCount}"
| Tester unitTestsPerHour ->
$"My testing speed is %f{unitTestsPerHour} unit tests per hour"
На C# можно переписать таким образом:
string GetWorkerInfo(Worker worker)
{
return worker switch
{
Worker.Developer(var knownLanguages) =>
$"Known languages {string.Join(',', knownLanguages)}",
Worker.Manager(var maintainedProjectsCount) =>
$"Currently maintained projects count {maintainedProjectsCount}",
Worker.Tester(var unitTestsPerHour) =>
$"My testing speed is {unitTestsPerHour} unit tests per hour",
_ =>
throw new ArgumentOutOfRangeException(nameof(worker), worker, null)
};
}
Нам становятся доступны подсказки IDE (Rider все равно ругается из-за отсутствия условия по-умолчанию):
Сравнение реализаций
C# | F# | |
Нахождение доступных вариантов | IDE (Варианты - классы-поля базового класса) | Теги (Enum) |
Реализуемые интерфейсы | IEquatable<Worker> | IEquatable<Worker> IStructuralEquatable |
Создание новых объектов | Конструктор | Статический метод (New*) |
Определение типа в райнтайме | Только рефлексия | Свойства для каждого варианта (Is*) |
Создаваемые свойства | Get/Set | Get-only |
Генерируемые методы сравнения | ==, !=, Equals | Equals |
Рекурсивное определение Discriminated Unions | Да, вариант выбора сделать абстрактным | Нет, определить другой DU выше и сделать вариантом выбора в текущем |
Представление в IL | Базовый абстрактный класс с наследующими его варантами-реализациями | |
Хранение данных для каждого варианта | Свойства с backing field | |
Деконструкция полей | Есть |
Примечания:
Методы Is* для Discriminated Unions под капотом используют рефлексию.
Выводы
Мой вариант основанный на record`ах сильно похож на тот, что генерируется компилятором F# (В чем-то даже превосходит).
Вариантов реализации много: на обычных классах, на структурах, partial классы.
Также преимуществом классовой реализации является возможность определения общих полей - в Discriminated Unions общие только свойства Tag и Is* для определения подтипа.
Если кому интересно как Discriminated Unions устроены более подробно, то существует пост на эту тему.
На этом у меня все. Если пропустил важные моменты, прошу поправить.