[Из песочницы] Enum и switch, и что с ними не так

image

Часто ли у вас было такое, что вы добавляли новое значение в enum и потом тратили часы на то, чтобы найти все места его использования, а затем добавить новый case, чтобы не получить ArgumentOutOfRangeException во время исполнения?


Идея

Если проблема состоит только в switch операторе и отслеживании новых типов, тогда давайте избавимся от них!

Идея состоит в том, чтобы заменить использование switch паттерном visitor.


Пример 1

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

Определим файл DocumentType.cs:

public enum DocumentType
{
    Invoice,
    PrepaymentAccount
}

public interface IDocumentVisitor
{
    T VisitInvoice();
    T VisitPrepaymentAccount();
}

public static class DocumentTypeExt
{
    public static T Accept(this DocumentType self, IDocumentVisitor visitor)
    {
        switch (self)
        {
            case DocumentType.Invoice:
                return visitor.VisitInvoice();
            case DocumentType.PrepaymentAccount:
                return visitor.VisitPrepaymentAccount();
            default:
                throw new ArgumentOutOfRangeException(nameof(self), self, null);
        }
    }
}

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

Опишем visitor который будет искать в базе нужный документ DatabaseSearchVisitor.cs:

public class DatabaseSearchVisitor : IDocumentVisitor
{
    private ApiId _id;
    private Database _db;

    public DatabaseSearchVisitor(ApiId id, Database db)
    {
        _id = id;
        _db = db;
    }

    public IDocument VisitInvoice() => _db.SearchInvoice(_id);
    public IDocument VisitPrepaymentAccount() => _db.SearchPrepaymentAccount(_id);
}

И потом его использование:

public void UpdateStatus(ApiDoc doc)
{
    var searchVisitor = new DatabaseSearchVisitor(doc.Id, _db);

    var databaseDocument = doc.Type.Accept(searchVisitor);

    databaseDocument.Status = doc.Status;

    _db.SaveChanges();
}


Пример 2

У нас есть события, которые выглядят следующим образом:

public enum PurseEventType
{
    Increase,
    Decrease,
    Block,
    Unlock
}

public sealed class PurseEvent
{
    public PurseEventType Type { get; }
    public string Json { get; }

    public PurseEvent(PurseEventType type, string json)
    {
        Type = type;
        Json = json;
    }
}

Мы хотим отправлять уведомления пользователю на определенный тип событий. Тогда реализуем visitor:

public interface IPurseEventTypeVisitor
{
    T VisitIncrease();
    T VisitDecrease();
    T VisitBlock();
    T VisitUnlock();
}

public sealed class PurseEventTypeNotificationVisitor : IPurseEventTypeVisitor
{
    private readonly INotificationManager _notificationManager;
    private readonly PurseEventParser _eventParser;
    private readonly PurseEvent _event;

    public PurseEventTypeNotificationVisitor(PurseEvent @event, PurseEventParser eventParser, INotificationManager notificationManager)
    {
        _notificationManager = notificationManager;
        _event = @event;
        _eventParser = eventParser;
    }

    public Missing VisitIncrease() => Missing.Value;

    public Missing VisitDecrease() => Missing.Value;

    public Missing VisitBlock()
    {
        var blockEvent = _eventParser.ParseBlock(_event);
        _notificationManager.NotifyBlockPurseEvent(blockEvent);
        return Missing.Value;
    }

    public Missing VisitUnlock()
    {
        var blockEvent = _eventParser.ParseUnlock(_event);
        _notificationManager.NotifyUnlockPurseEvent(blockEvent);
        return Missing.Value;
    }
}

Для примера не будем ничего возвращать. Для этого можно воспользоваться типом Missing из System.Reflection или же написать тип Unit. В реальном проекте возвращался бы Result, например, с информацией об ошибке, если такие имеются.

И пример использования:

public void SendNotification(PurseEvent @event)
{
    var notificationVisitor = new PurseEventTypeNotificationVisitor(@event, _eventParser, _notificationManager);
    @event.Type.Accept(notificationVisitor);
}


Дополнение


Если нужно быстрее

Если нужно использовать такой подход там, где важна производительность, в качестве visitor можно использовать структуры. Тогда код изменится следующим образом.

Метод расширение:

public static T Accept(this DocumentType self, in TVisitor visitor)
    where TVisitor : IDocumentVisitor
    {
        switch (self)
        {
            case DocumentType.Invoice:
                return visitor.VisitInvoice();
            case DocumentType.PrepaymentAccount:
                return visitor.VisitPrepaymentAccount();
            default:
                throw new ArgumentOutOfRangeException(nameof(self), self, null);
        }
    }

Сам visitor остаётся прежним, только меняем class на struct.

И сам код обновления документа выглядит не так удобно, но работает быстро:

public void UpdateStatus(ApiDoc doc)
{
    var searchVisitor = new DatabaseSearchVisitor(doc.Id, _db);

    var databaseDocument = doc.Type.Accept(searchVisitor);

    databaseDocument.Status = doc.Status;

    _db.SaveChanges();
}

При таком использовании generic, необходимо уточнять типы самому, так как компилятор не хочет способен вывести их автоматически.


Читабельность и in-place реализация

Если нужно реализовать логику только в одном месте, то часто visitor — громоздко и не удобно. Поэтому есть альтернативное решение match.

Сразу пример со структурой:

public static T Match(this DocumentType self, Func invoiceCase, Func prepaymentAccountCase)
{
    var visitor = new FuncVisitor(invoiceCase, prepaymentCase);
    return self.Accept, T>(visitor);
}

Сам FuncVisitor:

public readonly struct FuncVisitor : IDocumentVisitor
{
    private readonly Func _invoiceCase;
    private readonly Func _prepaymentAccountCase;

    public FuncVisitor(Func invoiceCase, Func prepaymentAccountCase)
    {
        _invoiceCase = invoiceCase;
        _prepaymentAccountCase = prepaymentAccountCase;
    }

    public T VisitInvoice() => _invoiceCase();
    public T VisitPrepaymentAccount() => _prepaymentAccountCase();
}

Использование match:

public void UpdateStatus(ApiDoc doc)
{
    var databaseDocument = doc.Type.Match(
        () => _db.SearchInvoice(doc.Id),
        () => _db.SearchPrepaymentAccount(doc.Id)
    );

    databaseDocument.Status = doc.Status;

    _db.SaveChanges();
}


Итог

При добавлении нового значения в enum необходимо:


  1. Добавить метод в интерфейс.
  2. Добавить его использование в метод расширение.

Для остальных мест компилятор подскажет нам, где необходимо реализовать новый метод.
Таким образом мы избавляемся от проблемы забытого case в switch.

Это все еще не серебряная пуля, но может здорово помочь в работе с enum.


Ссылки


© Habrahabr.ru