AI Discriminated unions в C#

AI

Редактор
Регистрация
23 Август 2023
Сообщения
2 968
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1

Введение


Небольшая статья об алгебраических типах данных и их суррогатах в C#.

Что означает этот термин?


Термин алгебраические типы данных пришёл из функциональной парадигмы.
В .NET экосистеме он предствален в F# и называется Discriminated unions.
Кроме F#, такие же типы существуют в Scala, Haskell, и других функциональных языках.
Кроме того многие языки мз парадигмы ООП реализовали свои варианты алгебраических типов данных.
К примеру, в Kotlin есть sealed classes, а в Java - sealed interfaces.

А что насчёт C#? В нём всё ещё нет нативной реализации, но разработка началась в этом году после многих лет переноса предложения на следующий год.
Но уже сейчас есть несколько библиотек от .NET сообщества эмулирующих поведение DU.

Что это вообще такое ваши DU?


Если вы всё ещё не знакомы с термином Discriminated Unions, то мы идём к вам проще всего понять их суть прочитав документацию на Microsoft Learn по F# Discriminated Unions.
Я бы описал DU как тип, который объединяет несколько вариантов, но при этом экземпляр данного типа может выражаться только в одном варианте.
Это как если бы C# enum имел бы свойства (поля), или ограничиться только одним уровнем наследования.
Проще всего начать с примера на F#. К примеру, нам нужно создать банковский счёт и обраб��тать платёж на основании этого банковского счёта.
В современном мире международный банковский счёт может быть представлен в нескольких системах, к примеру IBAN и SWIFT.

type BankAccountCommonData = { Title: string; BankName: string; BankAddress: string }

type BankAccount =
| Iban of common: BankAccountCommonData * Number: string
| Swift of common: BankAccountCommonData * Code: string
// Uncomment this line to get an warring
// | Routing of common: BankAccountCommonData * Routing: string

type BankAccountServiceDu =
static member CreateBankAccount(): BankAccount =
let number = ""
let commonData = { Title = ""; BankName = ""; BankAddress = "" }
Iban(commonData, number)

static member ProcessPayment (payment: string, account: BankAccount) : string =
let ProcessIban (payment: string, accountData: BankAccountCommonData, iban: string) =
// Logic here
String.Empty

let ProcessSwift (payment: string, accountData: BankAccountCommonData, swift: string) =
// Logic here
String.Empty

match account with
| Iban(common, number) -> ProcessIban(payment, common, number)
| Swift(common, code) -> ProcessSwift(payment, common, code)


Данный F# код выдаст ошибку если убрать комментарий с третьего варианта Routing в типе BankAccount - Warning FS0025 : Incomplete pattern matches on this expression.
Это указывает на то, что в switch покрыты не все варианты.
Этот тип предупреждения может быть переведён в ошибку, что не даст коду скомпилироваться и заставит программиста добавить обработчик на каждый вариант DU, но об этому чуть позже.

И для чего нужно?


Ответ простой и вытекает из описания выше - представить ветвящуюся логику или данные.
Наиболее близкий способ реализовать подобное поведение в C# это наследование.
Привет выше переведённый на C# с использованием наследования:

internal abstract record BankAccountBase
{
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
}

internal sealed record IbanBankAccount(string Number) : BankAccountBase;

internal sealed record SwiftBankAccount(string Code) : BankAccountBase;

internal static class BankAccountServiceInheritance
{
internal static BankAccountBase GetBankAccount()
{
// Logic here

return new IbanBankAccount("123")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
};
}

internal static void ProcessedPayment(string payment, BankAccountBase bankAccount)
{
string ProcessIban(IbanBankAccount iban, string paymentInfo)
{
// Logic here
return string.Empty;
}

string ProcessSwift(SwiftBankAccount iban, string paymentInfo)
{
// Logic here
return string.Empty;
}

var result = bankAccount switch
{
IbanBankAccount iban => ProcessIban(iban, payment),
SwiftBankAccount swift => ProcessSwift(swift, payment),
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), typeof(BankAccount).ToString(), null)
};

// Logic here
}
}


"Разве нужно что-то улучшать в этом коде?" вы можете сказать, но я возражу - DU имеют одну очень вашу фичу - exhaustive switch.
F#, Scala, Haskell, Rust, Java, Kotlin, и даже Dart уже имею собственные реализации алгебраических типов данных вместе с exhaustive switch.
"Что значит exhaustive switch?" Значит именно то что написано, на русском это буквально исчерпывающий переключатель.
Это такой switch который требует указать все варианты переменной имеющей конечное множество значений.
Обычно в C# коде мы достигаем исчерпывающего поведения при помощи default arm, где указываем значение по умолчанию или выбрасываем ошибку.
В случае если кто-то добавил новый вариант банковского аккаунта, к примеру на основе Routing number для Британии, и забыл указать этот вариант в switch в методе ProcessedPayment, то код скомпилируется и уже в процессе работы программы вылетит ArgumentOutOfRangeException вместо обработки платежа на основе Routing number.
При использовании exhaustive switch мы можем предотвратить появление такого бага.

C# DU


Мы можем достичь в некоторой степени исчерпывающего switch работая только с функциональностью C# из коробки просто используя record (class or struct), в которой в качестве полей указаны все ветвления данных или логики, сгенерированного компилятором метода deconstruct из record и switch expression.
Взгляните на метод ProcessedPaymentExhaustive ниже, выглядит не очень и читается так себе, но оно работает!
Если кто-то добавить новое свойство в BankCreateResultTuples, то вылетит ошибка компиляции, поскольку сгенерированный компилятором метода deconstruct включает по умолчанию все свойства.

internal abstract record BankAccountBase
{
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
}

internal sealed record IbanBankAccount(string Number) : BankAccountBase;

internal sealed record SwiftBankAccount(string Code) : BankAccountBase;

internal readonly record struct BankCreateResultTuples(BankAccount.Iban? Iban, BankAccount.Swift? Swift);

internal static class BankAccountServiceRecord
{
internal static BankCreateResultTuples GetBankAccount()
{
// Logic here

return new BankCreateResultTuples(
null,
new BankAccount.Swift("123")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
}
);
}

/// <summary>
/// Non-exhaustive switch
/// </summary>
internal static void ProcessedPayment(BankCreateResultTuples bankAccount)
{
var result = bankAccount switch
{
{ Iban: null, Swift: not null } => "",
{ Iban: not null, Swift: null } => "",
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
};

// Logic here
}

/// <summary>
/// Has some exhaustiveness
/// </summary>
internal static void ProcessedPaymentExhaustive(BankCreateResultTuples bankAccount)
{
var result = bankAccount switch
{
(null, not null) => "",
(not null, null) => "",
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
};

// Logic here
}
}

OneOf


Наиболее распространённый суррогат DU в C# это библиотека OneOf.

internal readonly record struct BankAccountCommonData(string Title, string BankName, string BankAddress);

/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Iban(string Number, BankAccountCommonData CommonData);

/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Swift(string Code, BankAccountCommonData CommonData);

/// <summary>
/// A possible third variant of DU (not in use)
/// </summary>
internal readonly record struct Routing(string Route, BankAccountCommonData CommonData);

internal static class BankAccountServiceOneOf
{
/// <summary>
/// Return a DU that is presented by <see cref="OneOf{T0, T1}"/>
/// </summary>
internal static OneOf<Iban, Swift> GetBankAccount()
{
// Logic here

var commonData = new BankAccountCommonData("Title", "A bank", "An address");
return new Iban("Number", commonData);
}

/// <summary>
/// Consume a DU that is presented by <see cref="OneOf{T0, T1}"/>
/// Add a third generic to the account argument type to get a compiler error.
/// </summary>
internal static void ProcessPayment(string payment, OneOf<Iban, Swift> account)
{
var result = account.Match(
iban => ProcessIban(iban, payment),
swift => ProcessSwift(swift, payment)
);

string ProcessIban(Iban iban, string paymentInfo)
{
// Logic here
return string.Empty;
}

string ProcessSwift(Swift iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
}
}


Оно эмулирует поведение DU используя дженерики и перегрузку типов.
Оно работает в нескольких режимах - можно использовать базовый тип с дженериками или создать свой, то тогда этот должен быть класс.
OneOf<T, ...Tn> это просто обёртка над возвращаемым типом. Это struct, и позволяет использовать как class так struct в качестве дженерик параметров, что может быть полезно если вы озадачены потр��блением памяти или аллокациями в куче.
Что бы получить поведение exhaustive switch нужно использовать метод Match из самой структуры, в котором нужно указать метод-обработчик на каждый вариант возвращаемого значения.
Если кто-то добавил новый вариант в возвращаемый тип, то будет использована другая перегрузка типа с большим количеством вариантов, и код не скомпилируется пока в методе Match не будут указаны методы-обработчики на все варианты.
Самый большой недостаток этой библиотеки - монструозные подписи методов, возвращаемый тип может достигать 100 символов, так же нужно оборачивать в Task возвращаемый тип при смешении с синхронными методами.
Так же кто-то может найти недостатком то, что количество вариантов ограничено восемью, но в таком случа лучше пересмотреть логику.
PS: OneOf очень похож на тип Choice из F# (но под капотом этот всё тот же DU).

StaticCs | static-cs


Библиотека static-cs сделает стандартный switch исчерпывающим!

[Closed]
internal abstract record BankAccount
{
private BankAccount()
{
}

public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }

internal sealed record Iban(string Number) : BankAccount;

internal sealed record Swift(string Code) : BankAccount;

// Uncomment this line to get Error CS8509 during compilation.
// internal sealed record Wire(string Code) : BankAccount;
}

internal static class BankAccountServiceStaticSc
{
internal static BankAccount GetBankAccount()
{
return new BankAccount.Iban("Number")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
};
}

internal static void ProcessPayment(string payment, BankAccount account)
{
var result = account switch
{
BankAccount.Iban iban => ProcessIban(iban, payment),
BankAccount.Swift swift => ProcessSwift(swift, payment),
};

string ProcessIban(BankAccount.Iban iban, string paymentInfo)
{
// Logic here
return string.Empty;
}

string ProcessSwift(BankAccount.Swift iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
}
}


StaticCs очень прост в использовании потому что использует Roslyn analyzer и добавляет только один новый аттрибут - Closed, а всё остальное - функционал C# из коробки.
Чтобы использовать DU вместе и exhaustive switch, нам нужно создать базовый абстрактный class (record), сделать конструктор по умолчанию приватным, поместить все производные внутрь класса и пометить его атрибутом Closed.
Один важный момент — в switch нельзя использовать default arm, иначе это уничтожить его исчерпывающие свойства.
Еще одна очень полезная вещь — перевести предупреждение CS8509 в ошибку, чтобы предотвратить компиляцию кода в случае, если switch не охватывает все варианты переменной.
Возьмём всё тот же пример — кто-то добавил новый вариант банковского счета (Routing number для Великобритании) и забыл добавить этот вариант в switch в методе ProcessedPayment.
Теперь код НЕ компилируется, а компилятор выведет ошибку в консоль, и разработчик будет вынужден добавлять методы для обработки всех производных от базового класса.
Существуют и другие библиотеки для эмуляции поведения DU, но я предпочитаю StaticCs, потому что она имеет меньше внешних зависимостей и использует родной C# функционал.

PS: Выглядит знакомо?) Эта конструкция очень похожа на Kotlin sealed classes.

sealed class BankAccount {
abstract val title: String
abstract val bankName: String
abstract val bankAddress: String

data class Iban(
val number: String,
override val title: String,
override val bankName: String,
override val bankAddress: String
) : BankAccount()

data class Swift(
val code: String,
override val title: String,
override val bankName: String,
override val bankAddress: String
) : BankAccount()
}

fun createPayment(): BankAccount {
// Logic here
return BankAccount.Iban(number = "Number", title = "Title", bankName = "A bank", bankAddress = "An address")
}

fun processPayment(payment: String, account: BankAccount): String {

fun processIban(iban: BankAccount.Iban, paymentInfo: String): String {
// Logic here
return ""
}

fun processSwift(swift: BankAccount.Swift, paymentInfo: String): String {
// Logic here
return swift.bankName
}

return when (account) {
is BankAccount.Iban -> processIban(account, payment)
is BankAccount.Swift -> processSwift(account, payment)
}
}

Это замена Exception?


И да и нет.
Я считаю, что этот способ гораздо лучше подходит для описания бизнес-логики, чем сру выкидывание Exception, поскольку он явный и не зависит от блока try-catch, но для библиотечного или инфраструктурного кода, исключения могут подойти лучше.
Всегд�� нужно помнить - исключения и DU это только инструменты, а у каждого инструмента есть своя область применения.
 
Сверху Снизу