Pull to refresh

Comments 19

нужно оборачивать в Task возвращаемый тип при смешении с синхронными методами.

С синхронными или асинхронными?

Также, лучше поставить тэг языка непосредственно перед каждым примером кода, потому что иногда это не очевидно.

Также, не очень хорошее название метода: GetBankAccount(). Метод создаёт аккаунт, а не просто возвращает его. Значит, лучше назвать CreateBankAccount. Мелочь, но всё же)

Спасибо за статью, есть вопрос.

Не кажется ли все это антипаттерном? Как только у нас появится новый вид счета, мы будем вынуждены пройтись по всему коду и добавить во все switch новую ветку. А в случае с StaticCs еще и непосредственно в BankAccount.

Почему не использовать интерфейс или абстрактный класс с абстрактным методом? Например так:

interface IBankAccount
{
  void ProcessPayment(string payment);
}

public class SwiftAccount: IBankAccount
{
  public void ProcessPayment(string payment)
  {
    //...
  }
}

При этом все типы, которые пожелают быть IBankAccount должны будут реализовать этот интерфейс.

Зависит от количества и разнообразия операций. Например, может потребоваться добавить на веб страницу различные иконки в зависимости от типа аккаунта. Такой метод придется тянуть в интерфейс.

Не кажется ли все это антипаттерном? Как только у нас появится новый вид счета, мы будем вынуждены пройтись по всему коду и добавить во все switch новую ветку

Это не баг, это фича! Серьезно, это же замечательно что как только появится новый вид счета программист обязан пройтись по всем свитчам и точно не забудет обработать новый кейс, в f# можно даже сделать не законченный свитч это ошибкой компиляции!
Интерфейс тут не очень хорош, представим что вам нужно выводить на сайте этот объект и в зависимости от типа аккаунта отображение должно быть разным, в этом случае нам придется логику отображения класть в класс SwiftAccount , во-первых она там ни к месту, во-вторых логика отображения будет раскидана по разным классам, затем появится требование отображать его при печати на бумаге, опять придется класть логику отображения в классы аккаунта

Да, согласен пихать все в класс как бы не к месту. А что если воспользоваться паттерном visitor? Да, его придется поддерживать постоянно в рабочем состоянии при добавлении новых подклассов, но кажется это альтернатива:

interface IBankAccount
{
  void Accept(IBankAccountVisitor visitor);
}

public class SwiftAccount: IBankAccount
{
  public void Accept(IBankAccountVisitor visitor)
  {
    visitor.SwiftVisit(this);
  }
}

interface IBankAccountVisitor
{
  void Visit(IBankAccount account) => account.Accept(this);
  void SwiftVisit(SwiftAccount account);
  //... и все другие
}

class WebBankAccountPresenter: IBankAccountVisitor
{
/// тут специфика
}

IBankAccount account = new SwiftAccount();
IBankAccountVisitor visitor = new WebBankAccountPresenter();
visitor.Visit(account);

Из минусов - можно умышленно не делать настоящую реализацию Accept. Но это кажется вредительство

Вам придется заводить новый класс буквально каждый раз когда вы используете переменную типа IBankAccount. А в банк аккаунте скорее всего будут еще вложенные DU например Owner (напр. частное лицо или юр лицо (если юр лицо то ООО или ИП и тд и тп))

Новый класс Visitor придется заводить под задачу. Отображения на форме и т.д. Ну так в этом его предназначение и есть.

Это всего лишь идея попытаться уложить решение в стандартные паттерны.

Не кажется ли все это антипаттерном?

DU и подобные решения изобрели не вчера - такой подход уже очень давно существуют в функциональных языках, и даже относительно новый Rust использует DU :)

мы будем вынуждены пройтись по всему коду и добавить во все switch новую ветку

Вы не уловили суть такого подхода. Обрабатывать все варианты в каждом switch это и есть цель такого подхода.

Почему не использовать интерфейс или абстрактный класс с абстрактным методом?

Вы предлагаете отказаться от anemic model (только хранят данные), как в моих примерах, и перейти к reach model (моделям которых хранять своё состояние и имеют действия которые меняют это состояние). Или же путаете stateless services, которые не хранят данные, а только действия по преобразованию данных, и value types которые только хранять данные. В данных примерах BankAccount это анемичная модель.

Reach model это другой подход, более ООПшный, но уже редко используемый в современнои .NET. Хотя акторная модель, тот же Orleans может работать практически только с reach model.

Запрещаем наследоваться от BankAccount, делая конструктор приватным. Record в этом плане дырявый, поэтому только класс. Наследники могут быть только вложенными классами и они оба sealed. Пишем свой метод для исчерпывающего свича (две перегрузки, с возвращаемым значением и без). На нативный свич полагаться нет смысла, т.к. он не поддерживает исчерпывающий свич (частично работает для enum, но и то через задницу). Поэтому, единственный верный вариант - написать свой метод. Дефолтная ветка с Exception добавлена для подавления предупреждений. По идее, она никогда не должна выполниться. Навероне можно умудриться в неё зайти с помощью рефлексии.

public abstract class BankAccount
{
    private BankAccount()
    {
        Title = "";
        BankName = "";
        BankAddress = "";
    }
    public required string Title { get; init; }
    public required string BankName { get; init; }
    public required string BankAddress { get; init; }
    
    public sealed class Iban : BankAccount
    {
        public required string Number { get; init; }
    }

    public sealed class Swift : BankAccount
    {
        public required string Code { get; init; }
    }

    public R ExhaustiveSwitch<R>(Func<Iban, R> ibanCase, Func<Swift, R> swiftCase)
    {
        return this switch
        {
            Iban iban => ibanCase(iban),
            Swift swift => swiftCase(swift),
            _ => throw new ArgumentOutOfRangeException("this", GetType().ToString(), "This should not happen. Are you using reflection?")
        };
    }

    public void ExhaustiveSwitch(Action<Iban> ibanCase, Action<Swift> swiftCase)
    {
        switch(this)
        {
            case Iban iban: ibanCase(iban);
                break;
            case Swift swift: swiftCase(swift);
                break;
            default: throw new ArgumentOutOfRangeException("this", GetType().ToString(), "This should not happen. Are you using reflection?");
        }
    }
}

Используем вот так:

BankAccount account = new BankAccount.Swift()
{
    Title = "Тайтл",
    BankName = "Имя Банка",
    BankAddress = "Адрес Банка",
    Code = "0123456789",
};


string ProcessIban(BankAccount.Iban iban, string paymentInfo)
{
    return $"IBAN: { paymentInfo }";
}

string ProcessSwift(BankAccount.Swift swift, string paymentInfo)
{
    return $"SWIFT: { paymentInfo }";
}

void ProcessIbanConsole(BankAccount.Iban iban, string paymentInfo)
{
    System.Console.WriteLine( $"IBAN: { paymentInfo }" );
}

void ProcessSwiftConsole(BankAccount.Swift swift, string paymentInfo)
{
    System.Console.WriteLine($"SWIFT: { paymentInfo }");
}


var result = account.ExhaustiveSwitch(
    iban => ProcessIban(iban, "Transfer $1000"),
    swift => ProcessSwift(swift, "Transfer $1000")
);
System.Console.WriteLine(result);

account.ExhaustiveSwitch(
    iban => ProcessIbanConsole(iban, "Transfer $2000"),
    swift => ProcessSwiftConsole(swift, "Transfer $2000")
);

Никаких сторонних библиотек. Никакого шаманства с .editorconfig. Никаких неочевидных танцев с перегрузкой типов. Никакого Roslyn. Пользователь обязан указать лямбды (или методы) для обработки всех случаев, "забыть" ветку не выйдет. Расширить можно только внеся изменения в класс BankAccount, а следовательно, человек, вносящий изменения, обязан будет обновить методы ExhaustiveSwitch и ExhaustiveSwitch<R>. При расширении, старый пользовательский код сломается, до тех пор, пока не добавят обработку новых подклассов во все вызовы ExhaustiveSwitch. В общем, вроде бы все требования удовлетворены. Бойлерплейт конечно присутствует, особенно из-за невозможности использовать здесь record, но много ли таких алгебраических типов надо вам в системе? На мой взгляд, бойлерплейт здесь не критичен, именно по причине того, что таких типов надо не так уж и много. Но может это говорит привычка, а точнее отсутствие привычки такие типы использовать.

З.Ы. При необходимости, можно даже сделать такие перегрузки ExhaustiveSwitch, в которых какие-то обработчики будут требоваться строго, а другие будут необязательными параметрами. Если сделать перегрузку ExhaustiveSwitch, принимающую специальный класс с нужными делегатами, то можно вообще очень сложную логику накрутить. Ни один язык никогда из коробки такого не даст. Строго говоря, это уже будет и не алгебраический тип, а что-то по мотивам. Зато можно вручную контролировать, какие ветки обрабатывать обязательно, а какие нет.

Record в этом плане дырявый

Эмм, и какие в нём дыры?

Вообще-то вы можете написать свою библиотеку анальтернативу OneOf, но вряди получится что-то более нативное и локаничное чем StaticSc.

Под "дырами" record, наверное, товарищ имел ввиду struct, а именно то, что в структуре нельзя переопределить конструктор по умолчанию.

Не совсем. Я имел в виду именно record. Но проблема действительно с конструкторами. Раз уж по этому поводу возникло недопонимание, попробую развёрнуто пояснить на примерах. Эх, придётся повоевать с редактором комментариев, чтобы это всё аккуратно оформить.

Буду использовать пример с BankAccount выше, как основу, но для краткости выкину из него методы ExhaustiveSwitch ибо они не имеют отношения к делу.

Для начала перепишем пример с использованием record вместо class.

Скрытый текст
public abstract record BankAccount(string Title, string BankName, string BankAddress)
{
    private BankAccount() : this("", "", "") { }

    public sealed record Iban(string Title, string BankName, string BankAddress, string Number) : BankAccount(Title,BankName,BankAddress);
    public sealed record Swift(string Title, string BankName, string BankAddress, string Code) : BankAccount(Title,BankName,BankAddress);
}

Вроде бы, всё хорошо, конструктор по умолчанию успешно переопределён, а код стал сильно короче, даже без учёта выброшенных методов. Но есть проблемка. Объявление record через синтаксис вида record BankAccount(string Title, string BankName, string BankAddress) приводит к автоматическому созданию конструктора с тремя строковыми праметрами Title, BankName и BankAddress. Переопределить и скрыть его никак нельзя. А следовательно, кто угодно может теперь расширить BankAccount без каких либо препятствий. Например, вот так:

Просто берём и наследуемся, как будто так и надо
public record Mir(string Id) : BankAccount ("","","") { }
//Подразумевается, что запись Mir находится за пределами записи BankAccount

Конечно, мы можем переписать пример, не используя синтаксис с круглыми скобками, а объявив свойства вручную.

Объявляем свойства вручную. Лёгким движением, record превращается в "элегантный" class
public abstract record BankAccount
{
    private BankAccount() { }
    public required string Title { get; init; }
    public required string BankName { get; init; }
    public required string BankAddress { get; init; }
    public sealed record Iban : BankAccount
    {
        public required string Number { get; init; }
    }

    public sealed record Swift : BankAccount
    {
        public required string Code { get; init; }
    }
}

Но чем такой код будет отличаться от варианта с классом, кроме ключевого слова record? На самом деле, кое чем будет. Дело в том, что для записей автоматически генерируется конструктор копирования. И этот конструктор имеет уровень доступа protected. Иными словами, нет никаких проблем создать наследника от BankAccount, использовав этот автоматически сгенерированный конструктор.

Используем конструктор копирования, для обхода запрета на наследование
public record Mir : BankAccount
{
    public required string Id { get; init; }

    [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
    public Mir() : base(new Iban { BankAddress = "", BankName = "", Number = "", Title = "" })
    { }
}

Единственный способ хоть как-то с этим побороться, это переопределить у BankAccount конструктор копирования вручную.

Переопределяем конструктор копирования
public abstract record BankAccount
{
    private BankAccount() { }

    [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
    protected BankAccount(BankAccount bankAccount)
    {
        throw new Exception("Inheritance is not allowed");
    }
    public required string Title { get; init; }
    public required string BankName { get; init; }
        public required string BankAddress { get; init; }
    public sealed record Iban : BankAccount
    {
        public required string Number { get; init; }
    }

    public sealed record Swift : BankAccount
    {
        public required string Code { get; init; }
    }
}

Проблема в том, что изменить уровень доступа мы не можем, а можем только кинуть исключение. И если кто-то всё же попробует отнаследоваться от BankAccount, ошибка заметят лишь в рантайме, а вот во время компиляции никаких проблем не будет. Это уж не говоря о том, что вообще-то, recordу желательно иметь нормальный рабочий конструктор копирования. Не зря же компилятор нам его генерирует. Ну и не забываем о том, что от короткого синтаксиса уже пришлось отказаться, а в таком случае, зачем это всё? Класс работал лучше. Даже не так. Он работал идеально, если не считать некоторой многословности. А вот record - дырявый. Хотя, наверное не правильно ругать record. Сандали, вон, тоже дырявые. Но это не баг, и даже не совсем фича. Это их самая суть.

Резюмируя, можно сказать, что record просто не задуман по своему дизайну, чтобы мы игрались с его конструкторами, запрещали наследование, прятали там что-то и т.д. Это должен быть простой иммутабельный тип с value семантикой, по типу структуры, но только в отличие от структур, аллоцирующийся в куче. Идти против дизайна языка, воюя с тем, что нам автоматически генерирует компилятор - это всегда сомнительная идея. Если есть выбор, то лучше так не делать. Да и вообще, ради чего мы это пытаемся делать? Ради краткости синтаксиса? Серьёзно? Впрочем, даже её мы благополучно потеряли уже на втором примере.

Немного оффтопа

В какой-то степени, то же самое можно сказать и про всю идею с использованием DU в C#. Если прям очень нужно, язык нам это позволяет сделать штатными средствами и без сторонних библиотек. Именно это я и хотел показать своим первым комментарием. Несколько громоздко, что наверное отсекает значительную часть use case'ов, но тем не менее, когда надо - можно. И даже без каких-то оговорок. Код получается понятный, логика DU реализована как надо, никаких лишних зависимостей, да и бойлерплейт только на объявляющей стороне, а для пользовательского кода всё удобно. Что это значит на практике? Ну, лично для меня это как раз и значит, что я буду использовать этот подход ровно в тех случаях, когда он будет полезен несмотря на многословность синтаксиса. Что же до клепания DU на каждый чих, то если вам такое надо, то значит вы предпочитаете писать код в функциональном стиле, и на сегодня экосистема .net предоставляет вам аж целый язык F#. И если вы хотите писать в функциональном стиле, то и пишите на нём. Серьёзно, я считаю, что это наилучший вариант. Благо, среда исполнения одна. Можно совместить два языка, если не готовы отказаться от C# полностью. А C# это объектный язык, пусть и с примесями функциональщины. Ничего удивительного, что не все приёмы из ФП полноценно работают. Стоит ли заставлять их работать, подпирая код костылями? Я думаю, что нет. Уж точно не в продакшене. Так же как, например, не стоит пытаться писать в объектном стиле на языках не поддерживающих ООП. По мере развития C# расклад, конечно, может сильно поменяться. За последние годы и так дофига чего поменялось. В интересное время живём.

По поводу "дыр" развёрнуто ответил на комментарий ниже.

Что же касается написания библиотеки, то пример как раз был расчитан на то, чтобы показать как это можно делать без всяких библиотек и наиболее прямолинейным способом. Это хорошо подходит для образовательных целей, и как мне показалось, дополнит статью. Если вам нужно немного таких классов, то подход вполне годится и в продакшен. А если нужно много, то можно вообще использовать F#.

Ждём нативную поддержку в языке https://github.com/dotnet/csharplang/issues/8928 .

Вроде как в 2025г обсуждений стало больше, надеюсь в .Net 11 завезут preview, а в .Net 12 LTS можно будет нормально пользоваться.

Пока что все эти самодельные штуки создают больше головняка чем пользы по сравнению с DU в F#/Rust/etc

Проблема в том, что никакой даты реализации DU даже и близко нет (:
А StaticSc это буквально один аттрибут, а остальное - обычный C# с перебором производных типов в обычном switch. Даже если захотите от неё избавиться, то нужно всего лишь удалить [Closed] обычными средствами любого текстового редактора.

Есть очень симпотная по реализации библиотека (сделанная моим другом), Dusharp - выглядит она точно получше описанного выше.

Спасибо за подсказку ещё одного варианта)
Я нахожу её очень похожую на OneOf, но с один существенным преимуществом подписи методов выглядят как нативные типы C#.
Из недостатков – всё тот же дополнительный метод Match вместо родного switch и принудительный деконструкции вариантов юниона по полям, и кодогенерация.

Пример с BankAccount очень плохой и тут DU притянут за уши. Разница между IBAN и Swift - формат реквизитов, а не взаимоисключающие состояния сущности. DU хорош там, где варианты несут разную бизнес-логику (draft/signed/archived или success/error), а не просто отличаются набором полей. Здесь он не решает проблему, а лишь заменяет DTO

Sign up to leave a comment.

Articles