Всем привет. Среди многих интересных концепций, имеющихся в 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 устроены более подробно, то существует пост на эту тему.
На этом у меня все. Если пропустил важные моменты, прошу поправить.
Комментарии (9)
Stefanio
23.08.2022 15:26+1Лучшим способом реализовать Union Type было бы сделать монаду Either, а не встраивать это через ООП
Рекомендую ознакомиться с этой статьей, где описано, как устроена алгебра типов
https://bartoszmilewski.com/2015/01/13/simple-algebraic-data-types/
slonopotamus
23.08.2022 20:12+2throw new ArgumentOutOfRangeException(nameof(worker), worker, null)
Ну вот это вот убивает весь смысл. В языках с полноценной поддержкой не нужно писать дефолт-ветку. И если мы добавляем новое значение в юнион, то код, делающий паттерн-матчинг, перестаёт компилироваться, вынуждая обработать новое значение. А у вас при добавлении нового значения всё радостно скомпилируется, но потом будет падать в рантайме.
P.S. Паттерн visitor позволяет более типобезопасно реализовать эмуляцию switch'а, без дефолт-веток.
vabka
24.08.2022 02:34Поправка на счёт:
Создаваемые свойства
Get/Set
Не set, а init, который хоть и set, но всё-же компилятор выдаст ошибку при попытке вызвать set.
Хотя F# даже метод такой не добавляет
Druj
На языках где это из коробки вы получаете тайпчекинг по инвариантам, разбор через Active Patterns и возможность матчить с дополнительными проверками по значению. А в C# это нафига?
AshBlade Автор
Академический интерес)
IamStalker
Typescript дает это из коробки. Но подход интересный, кроме академического интереса, не вижу ничего более. Хотя интересно было узнать - юз кейс.
navferty
В каждом втором проекте встречается штука вроде Result<T>, у которого есть `T Result.Success` и `Error Result.Fail`. Вместо этого можно было бы объявлять сигнатуру метода, который отдает ровно один из указанных типов, вроде `(TResult Result | TError Error)`, без объявления вспомогательного типа-контейнера для каждого такого варианта (как в статье)