Подтвердить что ты не робот

Обтекание библиотеки с использованием интерфейсов без необходимости использования downcasts

Скажем, мой проект использует библиотеку LibFoo, которая обеспечивает ее функциональность через несколько классов, например FooA и FooB. Теперь существует ряд похожих библиотек (например, LibBar, который предоставляет BarA и BarB), которые предоставляют те же функции, что и LibFoo, и я хочу, чтобы пользователи моего проекта могли выбрать, какую библиотеку использовать, предпочтительно во время выполнения.

Для этого я создал "оберточный слой", который определяет интерфейсы, которые я ожидаю от библиотек. В моем примере этот слой содержит два интерфейса: IfaceA и IfaceB. Затем для каждой библиотеки, которую я хочу поддерживать, я создаю "уровень реализации", который реализует интерфейсы с использованием одной из библиотек.

Теперь моя проблема заключается в том, как хорошо реализовать уровень реализации. Чтобы продемонстрировать свою проблему, рассмотрим, что у нас есть следующие интерфейсы (показаны на С++, но должны быть применимы к аналогичным языкам):

class IfaceA
{
public:
    virtual void doSomethingWith(IfaceB& b) = 0;
    ...
};

class IfaceB
{
    ...
};

Классы уровня реализации LibFoo будут содержать объекты из соответствующих классов LibFoo. Операции в интерфейсах должны быть реализованы с использованием этих объектов. Следовательно (извините меня за ужасные имена):

class ConcreteAForFoo : public IfaceA
{
public:
    void doSomethingWith(IfaceB& b) override
    {
        // This should be implemented using FooA::doSomethingWith(FooB&)
    }

private:
    FooA a;
};

class ConcreteBForFoo : public IfaceB
{
public:
    FooB& getFooB() const
    {
        return b;
    }

private:
    FooB b;
};

Проблема заключается в реализации ConcreteAForFoo::doSomethingWith: его параметр имеет тип IfaceB&, но мне нужно получить доступ к деталям реализации ConcreteBForFoo, чтобы иметь возможность правильно реализовать метод. Единственный способ, которым я нашел это, - использовать уродливые понижения по всему месту:

void doSomethingWith(IfaceB& b) override
{
    assert(dynamic_cast<ConcreteBForFoo*>(&b) != nullptr);
    a.doSomethingWith(static_cast<ConcreteBForFoo&>(b).getFooB());
}

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

TL; DR

Учитывая уровень взаимозависимых интерфейсов (в том виде, в котором методы в одном интерфейсе получают ссылки на другие интерфейсы). Как реализации этих интерфейсов могут совместно использовать сведения о деталях без downcasting или раскрытия этих деталей в интерфейсах?

4b9b3361

Ответ 1

Это непростая задача. Система типов с С++ не совсем адекватна. Ничто в принципе не может (статически) помешать вашим пользователям создавать экземпляры IFaceA из одной библиотеки и IFaceB из другой библиотеки, а затем смешивать и сопоставлять их по своему усмотрению. Ваши варианты:

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

    template <typename tag>
    class IfaceA;
    template <typename tag>
    class IfaceB;
    
    template <typename tag>
    class IfaceA
    {
       virtual void doSomethingWith(IfaceB<tag>& b) = 0;
    };
    

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

  • Сделать интерфейс действительно взаимозаменяемым, чтобы пользователи могли смешивать и сопоставлять интерфейсы, реализованные разными библиотеками.
  • Скрыть отбросы за некоторым приятным интерфейсом.
  • Используйте шаблон посетителя (двойная отправка). Он устраняет приведения, но существует известная проблема циклической зависимости. Ацикличный посетитель устраняет проблему, введя некоторые динамические броски, так что это вариант # 3.

Вот базовый пример двойной отправки:

//=================

class IFaceBDispatcher;
class IFaceB 
{
   IFaceBDispatcher* get() = 0;
};

class IfaceA
{
public:
    virtual void doSomethingWith(IfaceB& b) = 0;
    ...
};

// IFaceBDispatcher is incomplete up until this point

//=================================================

// IFaceBDispatcher is defined in these modules

class IFaceBDispatcher
{
  virtual void DispatchWithConcreteAForFoo(ConcreteAForFoo*) { throw("unimplemented"); }
  virtual void DispatchWithConcreteAForBar(ConcreteAForBar*) { throw("unimplemented"); }

};
class ConcreteAForFoo : public IfaceA
{
   virtual void doSomethingWith(IfaceB& b) { b.DispatchWithConcreteAForFoo(this); }
}

class IFaceBDispatcherForFoo : public IFaceBDispatcher
{
   ConcreteBForFoo* b;
   void DispatchWithConcreteAForFoo(ConcreteAForFoo* a) { a->doSomethingWith(*b); }
};   

class IFaceBDispatcherForBar : public IFaceBDispatcher
{
   ConcreteBForBar* b;
   void DispatchWithConcreteAForBar(ConcreteAForBar* a) { a->doSomethingWith(*b); }
};   

class ConcreteBForFoo : public IFaceB 
{
   IFaceBDispatcher* get() { return new IFaceBDispatcherForFoo{this}; }
};

class ConcreteBForBar : public IFaceB 
{
   IFaceBDispatcher* get() { return new IFaceBDispatcherForBar{this}; }
};

Ответ 2

Правильный ответ:

  • Сделайте свои интерфейсы действительно совместимыми и взаимозаменяемыми, или
  • Ничего не меняйте. Продолжайте делать динамические трансляции.
  • Добавьте что-то еще к вашей системе типов, что делает невозможным выполнение пользователем неправильной вещи и смешивание несовместимых конкретных реализаций.

Ваша основная проблема прямо сейчас в том, что интерфейс/наследование является "ложью упрощения", с помощью которой я действительно подразумеваю, что ваши интерфейсы фактически не следуют LSP.

Если вы хотите исправить это, следуйте LSP и сделайте библиотеки смешаемыми, вам нужно сделать 1: исправить свой код, чтобы не использовать скрытые подробности реализации, и действительно следовать LSP. Но этот параметр в основном исключается из инструкции вопросов, где явно указано, что реализация классов несовместима, и мы, вероятно, должны предположить, что это всегда будет иметь место.

Предполагая, что библиотеки никогда не будут смешиваться, правильный ответ будет либо 2. продолжать использовать динамические приведения. Подумайте о том, почему:

Определение интерфейса OP буквально говорит, что ConcreteAForFoo может успешно doSomethingWith создать любой объект IFaceB. Но OP знает, что это неверно - на самом деле это должен быть ConcreteBForFoo из-за необходимости использовать некоторые детали реализации, которые не будут найдены в интерфейсе IFaceB.

Downcasting - это лучшее, что делать в этом конкретном описанном сценарии.

Помните: downcasting - это когда вы знаете тип объекта, но компилятор этого не делает. Компилятор знает только подпись метода. Вы знаете, что типы выполнения лучше, чем компилятор, потому что вы знаете, что одновременно загружается только одна библиотека.

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

(И используя dynamic_cast вместо static_cast, говорит "и да, потому что я немного параноик, пожалуйста, дайте runtime, пожалуйста, пропустите ошибку, если я когда-нибудь ошибусь в этом", например, если кто-то злоупотребляет библиотекой пытаясь смешивать несовместимые ConcreteImplementations.)

Вариант 3 добавляется туда, хотя я думаю, что это не то, что действительно хочет OP, так как это означает нарушение ограничения совместимости интерфейса, это еще один способ прекратить нарушение LSP и удовлетворить поклонников сильной типизации.

Ответ 3

Похоже на классическую двойную отправку.

class IfaceA
{
public:
    virtual void doSomethingWith(IfaceB& b) = 0;
    ...
};

class IfaceB
{
    virtual void doSomethingWith(IfaceA & a) = 0;
    ...
};

struct ConcreteAForFoo : public IfaceA {
    virtual void doSomethingWith (IfaceB &b) override {
        b.doSomethingWith(*this);
    }
};

struct ConcreteBForFoo : public IfaceB
{
    virtual void doSomethingWith(IfaceA & a) override {
        //b field is accessible here
    }

private:
    FooB b;
};

IfaceB:: doSomethingWith сигнатура может быть изменена для получения оптимального баланса между уровнем связи и уровнем доступа (например, ConcreteAForFoo может использоваться в качестве аргумента для доступа к его внутренним функциям по цене за плотную связь).

Ответ 4

Прежде всего, я хотел бы поблагодарить вас, потому что ваш вопрос приятно спросил.

Мое первое чувство состоит в том, что у вас есть проблема архитектуры, если вы хотите передать IfaceB&, но нужно использовать конкретный тип.

Однако я понимаю сложность того, что вы хотите сделать, поэтому я постараюсь дать вам более приятное решение, чем удручающее.

Начало вашей архитектуры - хороший выбор, потому что это шаблон адаптера (ссылка находится на С#, но даже если я не используйте этот langage какое-то время, пока все еще лучшее, что я нашел по этому вопросу!). И это именно то, что вы хотите сделать.

В следующем решении вы добавляете еще один объект c, ответственный за взаимодействие между a и b:

class ConcreteAForFoo : public IfaceA
{
    private:
        FooA a;
};

class ConcreteBForFoo : public IfaceB
{
    private:
        FooB b;
};

class ConcreteCForFoo : public IfaceC
{
    private:
        ConcreteAForFoo &a;
        ConcreteBForFoo &b;

    public:
        ConcreteCForFoo(ConcreteAForFoo &a, ConcreteBForFoo &b) : a(a), b(b)
        {
        }

        void doSomething() override
        {
            a.doSomethingWith(b);
        }
};

class IfaceC
{
    public:
        virtual void doSomething() = 0;
};

Вы можете использовать factory для получения ваших объектов:

class IFactory
{
    public:
        virtual IfaceA* getA() = 0;
        virtual IfaceB* getB() = 0;
        virtual IfaceC* getC() = 0;
};

class IFactory
{
    public:
        virtual IfaceA* getA() = 0;
        virtual IfaceB* getB() = 0;
        virtual IfaceC* getC() = 0;
};

class FooFactory : public IFactory
{
    private:
        IfaceA* a;
        IfaceB* b;
        IfaceC* c;

    public:
        IfaceA* getA()
        {
            if (!a) a = new ConcreteAForFoo();

            return a;
        }

        IfaceB* getB()
        {
            if (!b) b = new ConcreteBForFoo();

            return b;
        }

        IfaceC* getC()
        {
            if (!c)
            {
                c = new ConcreteCForFoo(getA(), getB());
            }

            return c;
        }
};

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

На этом этапе вы сможете обработать метод doSomething следующим образом:

factory = FooFactory();
c = factory.getC();

c->doSomething();

Возможно, лучшее решение, но мне нужен реальный код, чтобы сказать вам об этом. Надеюсь, это поможет вам.

Наконец, я хотел бы извиниться за мой С++ (и мой английский), это долгое время, когда я не писал код на С++ (и я знаю, что сделал хотя бы несколько ошибок (что означает null == this. a без указателя??)).

EDIT:

Еще одна возможность избежать двусмысленности предоставления возможности передачи интерфейса, тогда как вы хотите, чтобы конкретный тип состоял в использовании своего рода посредника (отвечающего за взаимодействие между экземплярами a и b), связанным с именем реестр:

class FooFactory : public IFactory
{
    private:
        IMediator* mediator;

    public:
        IfaceA* getNewA(name)
        {
            a = new ConcreteAForFoo();
            mediator = getMediator();
            mediator->registerA(name, *a);

            return a;
        }

        IfaceB* getNewB(name)
        {
            b = new ConcreteBForFoo();
            mediator = getMediator();
            mediator->registerB(name, *b);

            return b;
        }

        IMediator* getMediator()
        {
            if (!mediator) mediator = new ConcreteMediatorForFoo();

            return mediator;
        }
};

class ConcreteMediatorForFoo : public IMediator
{
    private:
        std::map<std::string, ConcreteAForFoo> aList;
        std::map<std::string, ConcreteBForFoo> bList;

    public:
        void registerA(const std::string& name, IfaceA& a)
        {
            aList.insert(std::make_pair(name, a));
        }

        void registerB(const std::string& name, IfaceB& b)
        {
            bList.insert(std::make_pair(name, b));
        }

        void doSomething(const std::string& aName, const std::string& bName)
        {
            a = aList.at(aName);
            b = bList.at(bName);

            // ...
        }
}

Затем вы можете обрабатывать взаимодействие экземпляров a и b следующим образом:

factory = FooFactory();
mediator = factory.getMediator();
a = factory.getNewA('plop');
bPlap = factory.getNewB('plap');
bPlip = factory.getNewB('plip');
// initialize a, bPlap and bPlip.

mediator->doSomething('plop', 'plip');

Ответ 5

Если целью является удаление downcast для конкретной реализации из интерфейса, вам нужно будет разоблачить конкретный тип интерфейса (но не его реализацию). Вот пример:

class LibFoo;
class LibBar;

class IfaceA;
class IfaceB;

template <typename LIB> class BaseA;
template <typename LIB> class BaseB;

struct IfaceA
{
    virtual ~IfaceA () {}
    virtual operator BaseA<LibFoo> * () { return 0; }
    virtual operator BaseA<LibBar> * () { return 0; }
    virtual void doSomethingWith (IfaceB &) = 0;
};

struct IfaceB {
    virtual ~IfaceB () {}
    virtual operator BaseB<LibFoo> * () { return 0; }
    virtual operator BaseB<LibBar> * () { return 0; }
};

Реализации BaseA и BaseB переопределяют соответствующий оператор преобразования. Они также знают о типах, с которыми он будет взаимодействовать. BaseA полагается на оператор преобразования для IfaceB, чтобы достичь соответствия BaseB, а затем отправляет соответствующий метод.

template <typename LIB>
struct BaseA : IfaceA {
    operator BaseA * () override { return this; }

    void doSomethingWith (IfaceB &b) override {
        doSomethingWithB(b);
    }

    void doSomethingWithB (BaseB<LIB> *b) {
        assert(b);
        doSomethingWithB(*b);
    }

    virtual void doSomethingWithB (BaseB<LIB> &b) = 0;
};

template <typename LIB>
struct BaseB : IfaceB {
    operator BaseB * () override { return this; }
};

Затем конкретная реализация может сделать то, что нужно сделать.

struct LibFoo {
    class FooA {};
    class FooB {};
};

struct ConcreteFooA : BaseA<LibFoo> {
    void doSomethingWithB (BaseB<LibFoo> &) override {
        //...
    }

    LibFoo::FooA a_;
};

struct ConcreteFooB : BaseB<LibFoo> {
    LibFoo::FooB b_;
};

При добавлении другой библиотеки в микс будет возникать ошибка времени компиляции, если Iface не будет соответствующим образом расширен (но Base необязательно требует какого-либо расширения). Вы можете считать это следствие полезной функцией, а не вредной.

struct ConcreteBazA : BaseA<LibBaz> { // X - compilation error without adding
                                      //     conversion operator to `IfaceA`

Working example.


Если обновление всех объектов интерфейса не является параметром, то путь наименьшего сопротивления - использовать динамическое downcast, так как оно предназначено для решения именно этой проблемы.

struct IfaceA
{
    virtual ~IfaceA () {}
    template <typename LIB> operator BaseA<LIB> * () {
        return dynamic_cast<BaseA<LIB> *>(this);
    }
    virtual void doSomethingWith (IfaceB &) = 0;
};

struct IfaceB {
    virtual ~IfaceB () {}
    template <typename LIB> operator BaseB<LIB> * () {
        return dynamic_cast<BaseB<LIB> *>(this);
    }
};

Поскольку преобразование больше не является виртуальным, шаблоны BaseA и BaseB больше не нуждаются в методе переопределения.

template <typename LIB>
struct BaseA : IfaceA {    
    void doSomethingWith (IfaceB &b) override {
        doSomethingWithB(b);
    }

    void doSomethingWithB (BaseB<LIB> *b) {
        assert(b);
        doSomethingWithB(*b);
    }

    virtual void doSomethingWithB (BaseB<LIB> &b) = 0;
};

template <typename LIB>
struct BaseB : IfaceB {
};

Этот ответ можно рассматривать как иллюстрацию шаблона посетителя, предложенного n.m.

Ответ 6

В вашем дизайне можно одновременно создавать как libFoo, так и libBar. Также можно передать IFaceB из libBar в IFaceA:: doSomethingWith() из libFoo.

В результате вы принудительно на dynamic_cast вниз, чтобы гарантировать, что объект libFoo не будет передан объекту libBar и visaversa. Необходимо проверить, что пользователь не испортил.

Я вижу только две вещи, которые вы действительно можете сделать:

  • принять dynamic_cast для проверки вашего интерфейса на основе каждой функции.
  • перепроектируйте интерфейс так, чтобы ни один из параметров функции не был интерфейсом

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

Так, например, для libFoo:

IFaceA* libFooFactory (void)
{
    return new libFoo_IFaceA ();
}

IFaceB* libFoo_IFaceA::CreateIFaceB (void)
{
    return new libFoo_IFaceB (this);
}

.
.
.

libFoo_IFaceB::libFoo_IFaceB (IFaceA* owner)
{
    m_pOwner = owner;
}

libFoo_IFaceB::doSomething (void)
{
    // Now you can guarentee that you have libFoo_IFaceA and libFoo_IFaceB
    m_pOwner-> ... // libFoo_IFaceA
}

Использование выглядит так:

IFaceA* libFooA = libFooFactory ();
IFaceB* libFooB = libFooA->CreateIFaceB();

libFooB->doSomething();

Ответ 7

@Job, я не могу поместить это в комментарий, но он должен быть там. Какие методы вы пытались использовать для поиска решения этой проблемы? И почему эти методы не сработали? Знание этого поможет другим объяснить, почему метод может действительно работать или избежать дублирования усилий.

Я думаю, что самая большая проблема для вашей проблемы состоит в том, что вы ожидаете, что реализация IfaceA должна вызвать функцию неизвестной функции реализации IfaceB. Это всегда приведет к тому, что вы должны передать переданный объект, чтобы перейти к нужному методу, если только функция, которую вы вызываете, находится в интерфейсе IfaceB.

class IfaceB
{
public:
    virtual void GetB() = 0; // Added function so I no longer have to cast.
}

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

Также я заметил в ваших комментариях:

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

Это на самом деле огромное влияние на ваш дизайн. Я вижу, что у вас есть 2 вещи:

  • Попытка с высокой степенью сцепления, но принудительная связь, позволяющая реализациям звонить друг другу.
  • Похоже, что вы создаете слой абстракции данных (DAL).

Недавно я реализовал DAL для обработки объектов, которые создают изображение (JPEG, PNG, BMP и т.д.). Я использовал интерфейс для основных функций и factory для обработки создания объектов. Это получилось здорово. Мне никогда не нужно ничего обновлять, кроме добавления нового класса для обработки другого типа изображения. Вы можете сделать то же самое с реализацией Iface#, используя Templated Factory. Это позволит вам иметь собственный регистр классов с factory, а вызывающий код получает объект на основе зарегистрированного имени.

Ответ 8

Удалить методы doSomethingWith() из интерфейсов. Предоставьте бесплатную функцию:

void doSomethingWith(ConcreteAForFoo & a, ConcreteBForFoo & b);

Важно, чтобы тип отображал то, что он мог делать, и то, что он не может. Вы не можете обрабатывать произвольные подтипы интерфейсов.

Ответ 9

Вот выстрел в решение на Java с использованием дженериков. Я не очень хорошо знаком с С++, поэтому не знаю, возможно ли подобное решение.

public interface IfaceA<K extends IfaceB> {

  void doSomething(K b);

}


public interface IfaceB {

}


public class ConcreteAForFoo implements IfaceA<ConcreteBForFoo> {

  private FooA fooA;

  @Override
  public void doSomething(ConcreteBForFoo b) {
    fooA.fooBar(b.getFooB());
  }

}


public class ConcreteBForFoo implements IfaceB {

  private FooB fooB;

  public FooB getFooB() {
    return fooB;
  }

}


public class FooA {

  public void fooBar(FooB fooB) {
  }

}


public class FooB {

}