Всем привет. Среди многих интересных концепций, имеющихся в 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;
}

Данная реализация подходит, под описанные выше критерии:

  1. Ограниченный набор вариантов - все варанты выбора - внутри другого класса с приватным конструктором.

  2. Каждый вариант состоит из своего набора данных - каждый вариант это отдельный класс.

  3. Объединенные общим названием/подтипом - все наследуют базовый абстрактный класс.

В данной реализации я использовал 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)


  1. Druj
    23.08.2022 12:08
    +2

    На языках где это из коробки вы получаете тайпчекинг по инвариантам, разбор через Active Patterns и возможность матчить с дополнительными проверками по значению. А в C# это нафига?


    1. AshBlade Автор
      23.08.2022 12:55
      +1

      Академический интерес)


    1. IamStalker
      23.08.2022 13:46
      +1

      Typescript дает это из коробки. Но подход интересный, кроме академического интереса, не вижу ничего более. Хотя интересно было узнать - юз кейс.


      1. navferty
        23.08.2022 17:20
        +1

        В каждом втором проекте встречается штука вроде Result<T>, у которого есть `T Result.Success` и `Error Result.Fail`. Вместо этого можно было бы объявлять сигнатуру метода, который отдает ровно один из указанных типов, вроде `(TResult Result | TError Error)`, без объявления вспомогательного типа-контейнера для каждого такого варианта (как в статье)


  1. NN1
    23.08.2022 13:03

    В Nemerle так и реализуется variant.

    Там ещё добавляются разные дополнительные методы и метаданные, чтобы потом сопоставление с образцом сделать.

    Если интересно можете собрать простой variant и посмотреть код через dnSpy.


  1. Stefanio
    23.08.2022 15:26
    +1

    Лучшим способом реализовать Union Type было бы сделать монаду Either, а не встраивать это через ООП

    Рекомендую ознакомиться с этой статьей, где описано, как устроена алгебра типов

    https://bartoszmilewski.com/2015/01/13/simple-algebraic-data-types/


  1. slonopotamus
    23.08.2022 20:12
    +2

    throw new ArgumentOutOfRangeException(nameof(worker), worker, null)

    Ну вот это вот убивает весь смысл. В языках с полноценной поддержкой не нужно писать дефолт-ветку. И если мы добавляем новое значение в юнион, то код, делающий паттерн-матчинг, перестаёт компилироваться, вынуждая обработать новое значение. А у вас при добавлении нового значения всё радостно скомпилируется, но потом будет падать в рантайме.

    P.S. Паттерн visitor позволяет более типобезопасно реализовать эмуляцию switch'а, без дефолт-веток.


  1. vabka
    24.08.2022 02:34

    Поправка на счёт:

    Создаваемые свойства
    Get/Set

    Не set, а init, который хоть и set, но всё-же компилятор выдаст ошибку при попытке вызвать set.

    Хотя F# даже метод такой не добавляет


  1. Leg01as
    24.08.2022 12:51

    Есть готовая библиотека, предоставляющая "почти" размеченные объединения:

    https://github.com/mcintyre321/OneOf