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

Идея


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

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

Пример 1


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

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

    public enum DocumentType
    {
        Invoice,
        PrepaymentAccount
    }
    
    public interface IDocumentVisitor<out T>
    {
        T VisitInvoice();
        T VisitPrepaymentAccount();
    }
    
    public static class DocumentTypeExt
    {
        public static T Accept<T>(this DocumentType self, IDocumentVisitor<T> 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<IDocument>
    {
        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<out T>
    {
        T VisitIncrease();
        T VisitDecrease();
        T VisitBlock();
        T VisitUnlock();
    }
    
    public sealed class PurseEventTypeNotificationVisitor : IPurseEventTypeVisitor<Missing>
    {
        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<TVisitor, T>(this DocumentType self, in TVisitor visitor)
        where TVisitor : IDocumentVisitor<T>
        {
            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<DatabaseSearchVisitor, IDocument>(searchVisitor);
    
        databaseDocument.Status = doc.Status;
    
        _db.SaveChanges();
    }

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

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

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

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

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

Сам FuncVisitor:

    public readonly struct FuncVisitor<T> : IDocumentVisitor<T>
    {
        private readonly Func<T> _invoiceCase;
        private readonly Func<T> _prepaymentAccountCase;
    
        public FuncVisitor(Func<T> invoiceCase, Func<T> 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.

Ссылки