Эффективная и элегантная абстракция интерфейса

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

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

AbstractInterface.h:

// Forward declarations of abstract types.
class TypeA;
class TypeB;
class TypeC;

TypeA *foo(TypeA *a, TypeB *b);
TypeB *bar(std::vector<TypeC*> &c);
TypeC *baz(TypeC *c, TypeA *c);

ImplementationOne.cpp:

class ActualTypeA {...};
using TypeA = ActualTypeA;   // Error!
...

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

class TypeA : public ActualTypeA {};   // No more error
...
TypeA *foo(TypeA *a, TypeB *b)
{
    return actualFoo(a, b);   // Error
}

Здесь factFoo() возвращает ActualTypeA*, который не может быть автоматически преобразован в TypeA*. Поэтому я должен переписать его во что-то вроде:

inline TypeA *A(ActualTypeA *a)
{
    return reinterpret_cast<TypeA*>(a);
}    

TypeA *foo(TypeA *a, TypeB *b)
{
    return A(actualFoo(a, b));
}

Причина, по которой я использую вспомогательную функцию A(), заключается в том, что я случайно не привожу что-то отличное от ActualTypeA* к TypeA*. В любом случае, я не в восторге от этого решения, потому что мой фактический интерфейс состоит из десятков тысяч строк кода для каждой реализации. И все A(), B(), C() и т. д. затрудняют чтение.

Кроме того, реализация bar() потребует некоторого дополнительного колдовства:

inline std::vector<ActualTypeC*> &C(std::vector<TypeC*> &t)
{
    return reinterpret_cast<std::vector<ActualTypeC*>&>(t);
}

TypeB *bar(std::vector<TypeC*> &c)
{
    B(actualBar(C(c));
}

Еще один способ обо всем этом, который позволяет избежать каких-либо изменений на стороне реализации:

AbstractInterface.h:

class ActualTypeA;
class ActualTypeB;
class ActualTypeC;

namespace ImplemetationOne
{
    using TypeA = ActualTypeA;
    using TypeB = ActualTypeB;
    using TypeC = ActualTypeC;
}

class OtherActualTypeA;
class OtherActualTypeB;
class OtherActualTypeC;

namespace ImplemetationTwo
{
    using TypeA = OtherActualTypeA;
    using TypeB = OtherActualTypeB;
    using TypeC = OtherActualTypeC;
}

// Pre-define IMPLEMENTATION as ImplementationOne or ImplementationTwo
using TypeA = IMPLEMENTATION::TypeA;
using TypeB = IMPLEMENTATION::TypeB;
using TypeC = IMPLEMENTATION::TypeC;

TypeA *foo(TypeA *a, TypeB *b);
TypeB *bar(std::vector<TypeC*> &c);
TypeC *baz(TypeC *c, TypeA *c);

Это связано с тем, что кто-то может случайно использовать типы, специфичные для реализации, вместо абстрактных. Кроме того, это требует определения IMPLEMENTATION для каждой единицы компиляции, которая включает этот заголовок, и требует их согласованности. Я бы предпочел просто скомпилировать либо РеализацияOne.cpp, либо РеализацияТво.cpp, и все. Другим недостатком является то, что дополнительные реализации потребуют изменения заголовка, даже если нас не интересуют типы, специфичные для реализации.

Это кажется очень распространенной проблемой, поэтому мне интересно, не упустил ли я какое-либо решение, которое было бы более элегантным и все еще эффективным?


person Nicolas Capens    schedule 23.09.2016    source источник
comment
Похоже, вы предпринимаете огромные героические усилия для решения проблем, для решения которых предназначалось наследование. Поскольку вы ищете интерфейсы времени компиляции, вам нужно будет посмотреть на шаблоны.   -  person Jon Trauntvein    schedule 23.09.2016
comment
Вызов дополнительного уровня функций является накладным, поэтому он не может быть решением вашей проблемы. Короче говоря, я подозреваю, что вы выбираете какие-то случайные вещи, так как не делайте этого, у них есть цена, а другие, если вы делаете это, это не имеет стоимости без профилирования или уверенности. Являются ли два интерфейса действительно биективными: метод для метода, функция для функции и тип для типа?   -  person Yakk - Adam Nevraumont    schedule 23.09.2016
comment
Вы можете использовать tag-dispatch или SFINAE для выбора реализации во время компиляции, если доступна информация для выбора реализации. Это полиморфизм времени компиляции, но я полагаю, вы просто хотите избежать полиморфизма времени выполнения.   -  person midor    schedule 23.09.2016
comment
@Yakk Нет, библиотека, которую я сейчас использую для реализации интерфейса, не соответствует ему идеально. В основном у меня есть тонкая обертка поверх библиотеки. Стоимость этого слоя вполне оправдана. Но теперь я хочу использовать альтернативную библиотеку для реализации того же интерфейса (с абстрактными типами). Добавление накладных расходов для этой абстракции не оправдано, поскольку реализация выбирается во время компиляции.   -  person Nicolas Capens    schedule 24.09.2016


Ответы (5)


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

#include<type_traits>
#include<vector>

struct ActualTypeA {};
struct ActualTypeB {};
struct ActualTypeC {};

struct OtherActualTypeA {};
struct OtherActualTypeB {};
struct OtherActualTypeC {};

enum class Lib { LibA, LibB };

template<Lib>
struct Traits;

template<>
struct Traits<Lib::LibA> {
    using TypeA = ActualTypeA;
    using TypeB = ActualTypeB;
    using TypeC = ActualTypeC;
};

template<>
struct Traits<Lib::LibB> {
    using TypeA = OtherActualTypeA;
    using TypeB = OtherActualTypeB;
    using TypeC = OtherActualTypeC;
};

template<Lib L>
struct Wrapper {
    using LibTraits = Traits<L>;

    static typename LibTraits::TypeA *foo(typename LibTraits::TypeA *a, typename LibTraits::TypeB *b) { return nullptr; }
    static typename LibTraits::TypeB *bar(std::vector<typename LibTraits::TypeC*> &c) { return nullptr; }
    static typename LibTraits::TypeC *baz(typename LibTraits::TypeC *c, typename LibTraits::TypeA *a) { return nullptr; }
};

int main() {
    using MyWrapper = Wrapper<Lib::LibB>;
    static_assert(std::is_same<decltype(MyWrapper::foo(nullptr, nullptr)), OtherActualTypeA*>::value, "!");
    static_assert(std::is_same<decltype(MyWrapper::baz(nullptr, nullptr)), OtherActualTypeC*>::value, "!");
}
person skypjack    schedule 23.09.2016
comment
Это похоже на те же проблемы, что и решение с использованием макроса IMPLEMENTATION. - person Nicolas Capens; 28.09.2016

Не похоже, что C++ поддерживает способ определения предварительно объявленного класса как существующего класса. Так что в итоге я все равно использовал подход с приведениями (и вспомогательными функциями). Это стало изменением на 650 строк, но, по крайней мере, это гарантирует отсутствие дополнительных накладных расходов.

Я думаю, что я предложу комитету по стандартам C++ добавить языковую функцию для этого (или просто расслабить typedef/использование, чтобы не создавать ошибку переопределения), чтобы упростить ее в будущем...

person Nicolas Capens    schedule 27.09.2016

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

Вы можете либо использовать макросы для выбора реализации, либо сделать так, чтобы ваша система сборки компилировала только подмножество ваших исходников.

person Alexey Guseynov    schedule 23.09.2016
comment
Ваше решение, кажется, просто повторяет реальную проблему. Как написать две оболочки, реализующие один и тот же интерфейс? Кажется, что наиболее очевидным ответом является использование приведения типов, но это не очень элегантно. Есть ли способ перенаправить объявление типа, а затем определить его таким же, как существующий тип? - person Nicolas Capens; 24.09.2016
comment
Мое решение состоит в том, чтобы написать две реализации, но скомпилировать только одну из них. Вы либо используете свою систему сборки, чтобы пропустить файлы, которые вам в данный момент не нужны, либо полностью помещаете реализации под #ifdef end, оставляя только один из них. - person Alexey Guseynov; 24.09.2016
comment
Другими словами: если вы хотите собрать два варианта своего приложения с разными бэкенд-библиотеками, то это работа для системы сборки, а не для языковых ухищрений. - person Alexey Guseynov; 24.09.2016

"означает, что полиморфизм не может быть и речи"

Почему? Я бы сказал, что наследование — это именно то, что вам нужно… Что не так с полиморфизмом? Слишком медленно? Для чего? Это слишком медленно для того, что вы хотите? Или вы просто даете себе произвольное ограничение? Учитывая то, что я понял из вашего описания проблемы, полиморфизм - это именно то, что вам нужно! Вы хотите определить базовый класс B, определяющий контракт — набор методов. Вся ваша программа будет знать только этот базовый класс, никогда не ссылаясь на классы, производные от B. Затем вы реализуете 2 или более классов, производных от B — C и D — которые на самом деле имеют код в своих методах, фактически что-то делают. Ваша программа будет знать только B, вызывая его методы, не заботясь о том, код C или D, который на самом деле заставляет вещи происходить! Что вы имеете против полиморфизма? Это один из краеугольных камней ООП, так что вы можете просто перестать использовать C++ и придерживаться C...

person David C    schedule 23.09.2016
comment
Это высокопроизводительная платформа, которая уже используется несколькими клиентами. Я не могу допустить снижения производительности для текущей реализации, пока я абстрагирую интерфейс, чтобы обеспечить вторую реализацию. Кроме того, в этих реализациях используются сторонние библиотеки, которые я не могу контролировать, поэтому я не могу вывести их классы из общего базового класса. Я мог бы обернуть их, но тогда мне снова понадобятся приведения для их преобразования с повышением частоты. - person Nicolas Capens; 24.09.2016

«Я не могу допустить снижения производительности для текущей реализации»

Конечно вы можете. Однако нельзя допускать слишком падения производительности. Вы точно знаете, сколько производительности вы потеряете из-за полиморфизма? Вы потеряете производительность, но сколько именно %? Пробовали ли вы реализовать тестовую версию с надлежащей аппаратурой, чтобы убедиться, что в замедлении виноваты полиморфные вызовы? Знаете ли вы, как часто выполняются эти полиморфные вызовы? Иногда вы можете повысить производительность, не удаляя полиморфные вызовы — что, по IMO, является элегантным решением проблемы, которую он пытается решить, — а делая ваши интерфейсы менее болтливыми: кеширование результатов, объединение запросов и т. д. Вы не будете первым, кто попытается чтобы исключить очевидное решение S, потому что S медленнее на 700 мс, только для того, чтобы найти S, нужно было использовать 6 раз в час... :S

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

person David C    schedule 25.09.2016
comment
Все проблемы в информатике можно решить с помощью другого уровня косвенности... за исключением проблемы слишком большого количества уровней косвенности. - person Nicolas Capens; 26.09.2016