Pull to refresh

Двойная диспетчеризация

Reading time8 min
Views26K
Не так давно столкнулся по службе с весьма любопытной задачей. Но нет ничего нового под луной — и задача вам давно знакома: двойная диспетчеризация в C# в терминах статической типизации. Подробнее объясню ниже, но тем, кто и так всё понял, скажу: да, я буду применять «посетителя», но довольно необычно.

Ещё несколько оговорок, перед тем, как сформулировать задачу строже: я не буду останавливаться на том, почему мне не подходят dynamic, явная проверка типов через приведение и рефлексия. Тому две причины: 1) цель — избавиться от runtime исключений 2) хочу показать, что язык достаточно выразителен, даже если не прибегать к перечисленным средствам и оставаться в рамках строгой типизации.

Постановка


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

Пример:
  • скажем, есть некоторый интерфейс ICell, который выполняют классы RedCell, BlueCell, GreenCell.
  • есть правила следующего вида:
    1. получив две красных ячейки, пишем «красное на красном»
    2. получив зелёную и синюю пишем «берег»
    3. во всех прочих случаях — указать первый и второй цвет

  • прямым произведением множества ячеек на себя же получаем:

    красное на красном
    красный --> зелёный
    красный --> синий
    зелёный --> красный
    зелёный --> зелёный
    берег
    синий --> красный
    синий --> зелёный
    синий --> синий


Думаю, очевидно, что для решения такой задачи нам хватило бы одного класса вида:

internal class Cell
    {
        public Cell(string color)
        {
            Color = color;
        }

        public string Color { get; private set; }
    }

Разумеется, на практике нужно получить доступ к специфичным свойствам объектов, которые никак нельзя вынести в общий интерфейс, но загромождать пример сложной доменной областью не с руки, потому условимся: в общем интерфейсе нет ни слова о цвете, а все ячейки выглядят вот как эта красная ниже:

    internal interface ICell
    {
        //Some code
    }

    internal class RedCell : ICell
    {
        public string Color
        {
            get { return "красный"; }
        }
        //Some code
     }

Классический посетитель


Классикой уже стало решение такой задачи с одним элементом. Останавливаться на нём не стану, подробно об этом можно узнать, например, в Википедии. В нашем случае решение выглядело бы так следующим образом. При вот такой модели (обозначим этот код за [1], ниже вспомню о нём):

    internal interface ICell
    {
        T AcceptVisitor<T>(ICellVisitor<T> visitor);
    }

    internal interface ICellVisitor<out T>
    {
        T Visit(RedCell cell);

        T Visit(BlueCell cell);

        T Visit(GreenCell cell);
    }

    internal class RedCell : ICell
    {
        public string Color
        {
            get { return "красный"; }
        }

        public T AcceptVisitor<T>(ICellVisitor<T> visitor)
        {
            return visitor.Visit(this);
        }
    }

    internal class BlueCell : ICell
    {
        public string Color
        {
            get { return "синий"; }
        }

        public T AcceptVisitor<T>(ICellVisitor<T> visitor)
        {
            return visitor.Visit(this);
        }
    }

    internal class GreenCell : ICell
    {
        public string Color
        {
            get { return "зелёный"; }
        }

        public T AcceptVisitor<T>(ICellVisitor<T> visitor)
        {
            return visitor.Visit(this);
        }
    }

Простой посетитель легко решает нашу задачу:


internal class Visitor : ICellVisitor<string>
    {
        public string Visit(RedCell cell)
        {
            // здесь мы получаем доступ уже не к 
            // ICell, но к RedCell и к её не вынесенному
            // в интерфейс свойству Color
            return cell.Color;
        }

        public string Visit(BlueCell cell)
        {
            return cell.Color;
        }

        public string Visit(GreenCell cell)
        {
            return cell.Color;
        }
    }

Применение посетителя к задаче


Что ж, попытаемся обобщить имеющееся решение на случай двух элементов. Первое, что приходит в голову — превратить каждую ячейку в посетителя: свой собственный тип она и так знает, а, посетив свою товарку, узнает и её тип, следовательно, решит нашу задачу. Получается примерно вот что (для простоты напишу решение только для красной ячейки, а то кода и без того много):

    internal interface ICell
    {
        T AcceptVisitor<T>(ICellVisitor<T> visitor);
    }

    internal class RedCell<T> : ICell, ICellVisitor<T>
    {
        private readonly IProcessor<T> _processor;

        public RedCell(IProcessor<T> processor)
        {
            _processor = processor;
        }

        public TVisit AcceptVisitor<TVisit>(ICellVisitor<TVisit> visitor)
        {
            return visitor.Visit(this);
        }

        public string Color
        {
            get { return "red"; }
        }

        public T Visit(RedCell<T> cell)
        {
            return _processor.Get(this, cell);
        }

        public T Visit(BlueCell<T> cell)
        {
            return _processor.Get(this, cell);
        }

        public T Visit(GreenCell<T> cell)
        {
            return _processor.Get(this, cell);
        }
    }

    interface IProcessor<T>
    {
        T Get(RedCell<T> a, RedCell<T> b);
        T Get(RedCell<T> a, BlueCell<T> b);
        T Get(RedCell<T> a, GreenCell<T> b);
    }

Теперь всё, что нам остаётся — дописать простой процессор, и задача решена!

    internal class Processor : IProcessor<string>
    {
        public string Get(RedCell<string> a, RedCell<string> b)
        {
            return "красное на красном";
        }

        public string Get(RedCell<string> a, BlueCell<string> b)
        {
            return GeneralCase(a, b);
        }
        
        public string Get(RedCell<string> a, GreenCell<string> b)
        {
            return GeneralCase(a, b);
        }

        private string GeneralCase(ICell a, ICell b)
        {
            return a.Color + " --> " + b.Color;
        }
    }

Критика найденного решения


Что ж, действительно, решение найдено. Вам оно нравится? Мне — нет. Причины какие:
  • Нам необходимо написать N x N методов Visit: в каждой ячейке принять каждую; добавление ячейки нового цвета заставляет нас писать ещё N+1 новых методов. Если N достаточно велико (два десятка, например), то объём кода и трудозатрат устрашают;
  • Добавление любой новой ячейки необходимо приводит к изменению всех предыдущих. А если они в разных сборках, например? И, вполне вероятно, одну из этих сборок мы не может или не хотим менять;
  • В классы нашей модели мы добавили несвойственные им методы. Нужно пояснить — решить задачу, не добавив вовсе ни одного метода для посетителя невозможно (если вы можете — непременно мне расскажите!), но готовы ли мы терпеть N+1 дополнительный метод (в каждом классе!), не несущий никакой логической нагрузки? Увы, это синтаксический мусор;
  • Вы наверняка заметили, что ячейка стала обобщённой над T, именно:

    class RedCell<T> : ICell, ICellVisitor<T>
    

    Это уже совсем никуда не годится! Я не могу создать ячейку, не определившись заранее, какой тип будет возвращать заходящий в неё посетитель! Полнейший вздор.

    Разумеется, можно избавиться от обобщения: пусть посетитель ничего не возвращает, но изменяет своё состояние, которое выставит наружу открытым образом, но: (1) я предпочитаю immutable state и программирование, смещённое к функциональному (позвольте мне опустить мотивацию — думаю, она довольно понятна), значит, нужно избегать действий (Action) и стремиться использовать функции (Fun), значит, хорошо бы посетителю возвращать тип.

Надеюсь, перечисленного хватит, чтобы подтвердить моё мнение — решение так себе, однако, есть ещё одно важное замечание, на котором я хочу остановится подробнее. Задумаемся, сколько методов должен содержать IProcessor? N x N. То есть, очень много. Но ведь скорее всего нам нужно специальная обработка для очень небольшого, линейного по N, числа случаев. И тем не менее, мы не можем заранее знать, какие из них нам пригодятся (а мы ведь пишем framework, не правда ли? Структуру классов, основу, которыми все потом будут пользоваться, подключая нашу сборку к своим решениям).

Как его можно улучшить? Очевидный шаг: отделим модель от посетителя. Да, пусть, как и прежде, каждая ячейка умеет AcceptVisitor(...), но все методы Visit будут в отдельных классах. Несложно понять, что нам понадобится, в таком случае N+1 класс, каждый из которых содержит N методов Visit. Неслабо, правда? При этом любая новая ячейка приводит к добавлению нового класса + по методу в каждый из уже существующих.

Лучшее найденное решение


У меня есть решение, которое не обладает этими недостатками, а именно: мне нужно несколько классов (говорю без точности, потому что разные синтаксические красивости вроде fluent interface, от которых я не смог удержаться, прибавляют к этому числу, но использовать ли их — дело вкуса), причём число классов от N зависит; при добавлении новой ячейки мне понадобится добавить не зависящее от N число методов в эти классы.

Если (ну, мало ли) вы всё ещё читаете, то задумайтесь на мгновение, можете ли вы предложить решение, удовлетворяющее этим требованиям?

Если да, то здорово, напишите мне, но моё вот:

Модель у нас по-прежнему вида [1], а вот так (чтобы немного заинтриговать терпеливого читателя) будет выглядеть аналог конкретного процессора из предыдущего примера:

internal class ConcreteDispatcherFactory
    {
        public ICellVisitor<ICellVisitor<string>> CreateDispatcher()
        {
            return
                new PrototypeDispatcher<string>(Do)
                    .TakeRed.WithRed(Do)
                    .TakeGreen.WithBlue(Do);
        }

        private string Do(ICell a, ICell b)
        {
            var colorRetriever = new ColorRetriever();
            var aColor = a.AcceptVisitor(colorRetriever);
            var bColor = b.AcceptVisitor(colorRetriever);

            return aColor + "\t-->\t" + bColor;
        }

        private string Do(GreenCell a, BlueCell b)
        {
            return "побережье";
        }

        private string Do(RedCell a, RedCell b)
        {
            return "красное на красном";
        }
    }

где ColorRetriever — это простой «одинарный» визитёр:

internal class ColorRetriever : ICellVisitor<string>
    {
        public string Visit(RedCell cell)
        {
            return cell.Color;
        }

        public string Visit(BlueCell cell)
        {
            return cell.Color;
        }

        public string Visit(GreenCell cell)
        {
            return cell.Color;
        }
    }

[Перед, собственно, самим решением, небольшое отступлении об этом последнем ColorRetriever — хочу заострить читательское внимание на том, что сами по себе строчки
            var colorRetriever = new ColorRetriever();
            var aColor = a.AcceptVisitor(colorRetriever);
            var bColor = b.AcceptVisitor(colorRetriever);

ещё не решают поставленную задачу. С их помощью мы всего лишь последовательно получаем доступ к каждой из двух ячеек в виде явного типа, не скрытого интерфейсом, тогда как нам нужно получить такой доступ для обеих одновременно. Поправка внесена благодаря внимательности maxim_0_o, мой ему поклон]

Как видно, мы оговариваем общий случай и два частных, и этого будет достаточно, чтобы построить решение.
По большому счёту, нам понадобятся два класса — первый и второй посетитель. Второй будет обобщённым и первый создаст его типизированный экземпляр с тем, чтобы использовать для конкретной ячейки.

Вот первый:

class PrototypeDispatcher<TResult> : ICellVisitor<ICellVisitor<TResult>>
    {
        private readonly Builder<TResult, RedCell> _redBuilder;
        private readonly Builder<TResult, GreenCell> _greenBuilder;
        private readonly Builder<TResult, BlueCell> _blueBuilder;

        public PrototypeDispatcher(Func<ICell, ICell, TResult> generalCase)
        {
            _redBuilder = new Builder<TResult, RedCell>(this, generalCase);
            _blueBuilder = new Builder<TResult, BlueCell>(this, generalCase);
            _greenBuilder = new Builder<TResult, GreenCell>(this, generalCase);
        }

        public IBuilder<TResult, RedCell> TakeRed
        {
            get { return _redBuilder; }
        }

        public IBuilder<TResult, BlueCell> TakeBlue
        {
            get { return _blueBuilder; }
        }

        public IBuilder<TResult, GreenCell> TakeGreen
        {
            get { return _greenBuilder; }
        }

        public ICellVisitor<TResult> Visit(RedCell cell)
        {
            return _redBuilder.Take(cell);
        }

        public ICellVisitor<TResult> Visit(BlueCell cell)
        {
            return _blueBuilder.Take(cell);
        }

        public ICellVisitor<TResult> Visit(GreenCell cell)
        {
            return _greenBuilder.Take(cell);
        }
    }

Вот второй:

    internal class Builder<TResult, TA> : IBuilder<TResult, TA>, ICellVisitor<TResult> where TA : ICell
    {
        private Func<TA, RedCell, TResult> _takeRed;
        private Func<TA, BlueCell, TResult> _takeBlue;
        private Func<TA, GreenCell, TResult> _takeGreen;
        private readonly Func<ICell, ICell, TResult> _generalCase;

        private readonly PrototypeDispatcher<TResult> _dispatcher;
        private TA _target;

        public Builder(PrototypeDispatcher<TResult> dispatcher, Func<ICell, ICell, TResult> generalCase)
        {
            _dispatcher = dispatcher;
            _generalCase = generalCase;

            _takeRed = (a, b) => _generalCase(a, b);
            _takeBlue = (a, b) => _generalCase(a, b);
            _takeGreen = (a, b) => _generalCase(a, b);
        }

        public PrototypeDispatcher<TResult> WithRed(Func<TA, RedCell, TResult> toDo)
        {
            _takeRed = toDo;
            return _dispatcher;
        }

        public PrototypeDispatcher<TResult> WithBlue(Func<TA, BlueCell, TResult> toDo)
        {
            _takeBlue = toDo;
            return _dispatcher;
        }

        public PrototypeDispatcher<TResult> WithGreen(Func<TA, GreenCell, TResult> toDo)
        {
            _takeGreen = toDo;
            return _dispatcher;
        }

        public TResult Visit(RedCell cell)
        {
            return _takeRed(_target, cell);
        }

        public TResult Visit(BlueCell cell)
        {
            return _takeBlue(_target, cell);
        }

        public TResult Visit(GreenCell cell)
        {
            return _takeGreen(_target, cell);
        }

        public ICellVisitor<TResult> Take(TA a)
        {
            _target = a;
            return this;
        }
    }

И ещё интерфейс для красоты, чтобы отделить строителя от посетителя (которые в обоих классах сливаются, но зато синтаксис вызова красивый):

    internal interface IBuilder<TResult, out TA>
    {
        PrototypeDispatcher<TResult> WithRed(Func<TA, RedCell, TResult> toDo);
        PrototypeDispatcher<TResult> WithBlue(Func<TA, BlueCell, TResult> toDo);
        PrototypeDispatcher<TResult> WithGreen(Func<TA, GreenCell, TResult> toDo);
    }

В заключение хочу сослаться на серию статей «про волшебников и воинов», где тоже обсуждаются вопросы диспетчеризации в C#.
Tags:
Hubs:
+16
Comments37

Articles

Change theme settings