вторник, 8 июня 2010 г.

Посещение чуждых иерархий или посетитель, которого не ждут

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

На той же предметной области, что и в прошлом посте, покажу в чем проблема: требовалось класс Engine, который ничего не знает о посетителе, привести к интерфейсу ICarElement, который обеспечивает метод Accept, обращающийся к методу посетителя ICarElementVisitor<TResult>, соответствующий типу Engine. Адаптер решал эту проблему, однако, при необходимости адаптировать к интерфейсу ICarElement значительное количество классов (даже 10), без кодогенератора будет скучно. Каждый адаптер – 12 линий кода. 120 линий кода для адаптации 10 классов – явный перебор, если есть другие решения. А они есть!

Восстановим и упростим предметную область примера:
class CarElement
{ 
}

class Wheel : CarElement
{
}

class Engine : CarElement
{
}
    
class Car : CarElement
{
    public Wheel[] Wheels = Enumerable.Range(1, 4).Select(_ => new Wheel()).ToArray();
    public Engine Engine = new Engine();
}
Я оставил супертип у иерархии частей машины. Он играет только роль обобщающего типа. Если супертипа в вашей иерархии нет - можно вместо него использовать System.Object. Никакой ответственности на супертип CarElement не накладывается. Никаких методов типа Accept он не предоставляет. Считаем, что создатель этой иерархии не задумывался о применении паттерна посетитель.

Но нам хочется воспользоваться паттерном посетитель. Воспроизведу код из предыдущего поста. Единственное отличие – для класса Engine не используется адаптер.
interface ICarElementVisitor<out TResult>
{
    TResult Visit(Wheel visitable);
    TResult Visit(Engine visitable);
    TResult Visit(Car visitable);
}

class GetNameVisitor : ICarElementVisitor<string>
{
    public string Visit(Wheel visitable)
    {
        return "wheel";
    }

    public string Visit(Engine visitable)
    {
        return "engine";
    }

    public string Visit(Car visitable)
    {
        return "car";
    }
}

class GetChildrenVisitor : ICarElementVisitor<IEnumerable<CarElement>>
{
    public IEnumerable<CarElement> Visit(Wheel visitable)
    {
        yield break;
    }

    public IEnumerable<CarElement> Visit(Engine visitable)
    {
        yield break;
    }

    public IEnumerable<CarElement> Visit(Car visitable)
    {
        yield return visitable.Engine;
        foreach (var wheel in visitable.Wheels)
        {
            yield return wheel;
        }
    }
}
Еще раз хочу обратить внимание на то что код конкретных посетителей не зависит от способа диспетчеризации вызовов, и потому инвариантен к диспетчеризации. За диспетчеризацию будет отвечать следующий метод-расширение:
static class CarElementExtensions
{
    public static T Accept<T>(this CarElement element, ICarElementVisitor<T> visitor)
    {
        var wheel = element as Wheel;
        if(wheel != null)
            return visitor.Visit(wheel);

        var engine = element as Engine;
        if (engine != null)
            return visitor.Visit(engine);

        var car = element as Car;
        if (car != null)
            return visitor.Visit(car);

        throw new NotSupportedException();
    }
}
Осталось повторить код теста. Он остался неизменен.
static class Test
{
    public static void TestVisitor()
    {
        var childrenVisitor = new GetChildrenVisitor();
        var nameVisitor = new GetNameVisitor();

        CarElement car = new Car();

        var elements = car.Walk(e => e.Accept(childrenVisitor));
        var elementNames = elements.Select(e => e.Accept(nameVisitor));

        foreach (var elementName in elementNames)
        {
            Console.WriteLine(elementName);
        }
    }

    public static IEnumerable<T> Walk<T>(this T root, Func<T, IEnumerable<T>> next)
    {
        var q = next(root).SelectMany(n => Walk(n, next));
        return new[] { root }.Concat(q);
    }
}
Таким образом, для того чтобы добавить обход по некоторому классу, достаточно внести его в интерфейс посетителя ICarElementVisitor и добавить несколько строк в метод расширение Accept. Этот подход существенно экономнее, чем написание адаптеров к непослушным классам. Осталось только сделать замечание, что представленный в этом посте подход нельзя называть классическим посетителем.

Еще один штрих будет полезен тем, кто уже перешел на .NET 4, либо собирается переходить на него. Написание метода-расширения Accept можно водрузить на рантайм! Вот так:
static class CarElementExtensions
{
    public static T Accept<T>(this CarElement element, ICarElementVisitor<T> visitor)
    {
        return visitor.Visit((dynamic)element);
    }
}
Решение о том, какой из методов интерфейса ICarElementVisitor нужно вызывать для конкретного объекта CarElement, будет принято прямо во время выполнения программы. Полагаю, что это несколько снизит производительность, возможно стоит относиться к этому осторожно при посещении больших иерархий.

Последняя деталь – в связи с неожиданным поведением RuntimeBinder-а, не получается использовать следующую форму объявления интерфейса посетителя:
interface IVisitor<in TVisitable, out TResult>
{
    TResult Visit(TVisitable visitable);
}

interface ICarElementVisitor<out TResult> :
    IVisitor<Wheel, TResult>,
    IVisitor<Engine, TResult>,
    IVisitor<Car, TResult>
{
    //TResult Visit(Wheel visitable);
    //TResult Visit(Engine visitable);
    //TResult Visit(Car visitable);
}
Binder считает что у интерфейса ICarElementVisitor нет методов Visit. Это действительно так, но такое поведение отличается от поведения компилятора C#. Подозрение о том что это баг есть не только у меня.

Код с динамической диспетчеризацией вполне работоспособен с первым объявлением интерфейса ICarElementVisitor в этом посте.

3 комментария:

  1. dynamic - это конечно прикольно, но раз уж все типы и так должны быть известны в compile time, то почему бы не нагенерировать необходимых Accept с нужными типами и не играть в is/dynamic?

    ОтветитьУдалить
  2. Как я понимаю, речь идет о том чтобы нагенерить методов

    Accept(Engine, IVisitor visitor)

    Accept(Wheel, IVisitor visitor)
    и т.п.

    Это подход, но он не решает задачу двойной диспетчеризации, когда мы не знаем что за элемент у нас в руках, а знаем только что это один из CarElement, или шире - System.Object. Игры в is/dynamic позволяют клиентскому коду вызвать соответствующий типу метод, не разбираясь в типе объекта. Это частая для посетителя ситуация, но она не отражена в моем примере.
    Однако, генерация метода Accept(CarElement, IVisitor) с проверками is - это тоже вариант.

    ОтветитьУдалить
  3. Ой, слона-то я и не заметил. Конечно, без определения типа в рантайме визитор не посторить.

    ОтветитьУдалить