Полиморфные переменные-члены — дизайн класса

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

В настоящее время у меня есть абстрактный класс Response, производный от сериализуемого JSON Object.

//objects.h
struct Object
{
    [[nodiscard]] std::string serialize() const;

    virtual void deserialize(const Poco::JSON::Object::Ptr &payload) = 0;

    [[nodiscard]] virtual Poco::JSON::Object::Ptr to_json() const = 0;
};
// response.h
class Response : public Object
{
public:
    std::unique_ptr<Data> data;
    std::unique_ptr<Links> links;
};

Где переменные-члены Data и Links являются абстрактными базовыми классами, в которых их соответствующий набор подклассов содержит различные контейнеры STL.

Теперь проблема, с которой я сталкиваюсь, связана с дизайном класса и как избежать понижения каждой переменной-члена в зависимости от производного Response (и определить более чистую иерархию/дизайн). Например...

ResponseConcreteA response_a;
response_a.deserialize(object_a);
auto data_a = static_cast<DataConcreteA *>(response_a.data.get());

ResponseConcreteB response_b;
response_b.deserialize(object_b);
auto data_b = static_cast<DataConcreteB *>(response_b.data.get());

на первый взгляд очевидное решение — отказаться от полиморфных переменных-членов и заменить ими соответствующие конкретные типы. Однако меня беспокоит то, что это отклонение от неотъемлемого отношения Response имеющего Data и Links членов, каждый из которых относится к определенному полиморфному типу.

Важно отметить, что конкретные типы, приписываемые Data и Links, определяются во время компиляции - нет необходимости изменять производные классы в какой-либо момент. Соответствующие конструкции регулируются следующим предварительно обработанным шаблоном:

#define DECLARE_RESPONSE_TYPE(type_name, data_name, links_name \
        struct type_name final : public Response \
        { \
            type_name() \
            { \
                data.reset(new data_name()); \
                links.reset(new links_name()); \
            } \
            ~type_name() = default; \
            void deserialize(const Poco::JSON::Object::Ptr &payload) override; \
            Poco::JSON::Object::Ptr to_json() const override; \
        };

Есть ли более подходящий подход, чтобы избежать этих полиморфных переменных-членов в моем дизайне, где требуется постоянное понижение (несмотря на тот факт, что производный объект, на который указывает, известен во время компиляции). Спасибо!


person wubzorz    schedule 04.07.2020    source источник
comment
Рассматривали ли вы использование шаблона?   -  person Paul Sanders    schedule 04.07.2020
comment
Привет, @PaulSanders. Я рассматривал возможность использования шаблонов. Я просто не совсем уверен, как использовать их для решения этой проблемы. Первоначально я играл с CRTP, чтобы избежать накладных расходов на vtables, однако на самом деле это не решает эту конкретную проблему. Есть ли у вас рекомендации относительно того, как здесь можно использовать дженерики?   -  person wubzorz    schedule 04.07.2020
comment
Если тип конкретных типов, производных от Data и Links, определяется во время компиляции, то эти классы не обязательно должны быть полиморфными. Тогда Response может быть шаблонным типом, параметризованным по типу конкретных типов Data и Links. Все, что необходимо тогда, это чтобы все конкретные типы реализовали указанный интерфейс (который, при желании, может быть принудительно реализован путем получения их от абстрактной базы). Кроме того, если конкретные объекты Data и Links не создаются динамически, члены Response не обязательно должны быть unique_ptr — вместо этого они должны быть просто членами этого типа.   -  person Peter    schedule 04.07.2020
comment
Спасибо @PaulSanders - это имеет смысл. Мне просто любопытно, как переопределить чисто виртуальные методы в каждом конкретном типе Response. Если этот класс является шаблонным, я предполагаю, что мне придется принять специализацию шаблона и реализовать методы deserialize и to_json в соответствии со специализированными параметрами. Это правильно?   -  person wubzorz    schedule 04.07.2020
comment
Вам не нужна специализация шаблона. То, что вы, вероятно, ищете, — это дизайн на основе политик, где вы предоставляете шаблону политики десериализации и to_json в качестве параметров шаблона. Затем общий шаблон вызывает функции политики, когда это необходимо.   -  person PaulMcKenzie    schedule 04.07.2020
comment
Вы должны прочитать о ковариантных и контравариантных свойствах. Квадрат только для чтения — это разновидность прямоугольника только для чтения, но изменяемый квадрат — это не разновидность изменяемого прямоугольника; setWidth для изменяемого прямоугольника не изменяет высоту, но делает это для изменяемого квадрата, поэтому LSP не работает. Здесь у вас есть десериализатор; это похоже на изменяемый тип, и традиционное наследование сталкивается с проблемами, как вы продемонстрировали.   -  person Yakk - Adam Nevraumont    schedule 04.07.2020


Ответы (1)


(я адаптирую один из моих недавних комментариев Reddit, ответил в основном на тот же вопрос.)

в целом

Не моделируйте сериализацию с наследованием! Это сквозная проблема, которую вы хотите привязать к произвольным типам. Наследование — неподходящий инструмент для этого. Некоторые проблемы с подходом:

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

Сопоставление с образцом — более гибкий подход. Короче говоря, вы шаблонируете свою структуру сериализации и зависите от определенных функций, доступных для сериализуемых типов. Быстрый, грязный и наивный пример:

struct Something {
    // ...
};

// If these two functions exist a type has serialization support
void serialize(const Something&, SerializedDataStream&);
Something deserialize(SerializedDataStream&);

Теперь вы можете сделать что угодно сериализуемым, вообще не касаясь типа. Это намного более гибко, чем наследование, но, вероятно, делает структуру сериализации более сложной для реализации. Дополнительная поддержка функций-членов (де)сериализации — хорошая идея для более сложных типов, которым требуется доступ к их закрытым данным для правильной (де)сериализации.

Взгляните на Boost Serialization или Cereal для реальных примеров подхода сопоставления шаблонов.

в вашей конкретной ситуации

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

Важно отметить, что конкретные типы, относящиеся к данным и ссылкам, определяются во время компиляции.

Очевидное решение — превратить Response в шаблон.

template <typename Data, typename Links>
class Response  // note: no more base class
{
public:
    Data data;
    Links links;
};

// externalized serialization functions
void serialize(const Response&, JSONDataStream&);
Response deserialize(JSONDataStream&);

Таким образом, у вас есть правильные типы, доступные для поиска правильной перегрузки функций сериализации для Data и Links, а делегирование сводится к простому их вызову. Осуществимость такого подхода зависит от более широкого контекста. Внедрение шаблона в проект, основанный на полиморфизме, может привести к волновым эффектам во всей кодовой базе. Другими словами, это может быть очень дорогим изменением.

Альтернатива аналогична тому, что вы уже делаете. Сам Response по-прежнему использует подход к сериализации на основе сопоставления с образцом. Но вы сохраняете полиморфизм для Data и Links, включая переопределенные функции виртуальной сериализации. В каждом конкретном производном типе мы возвращаемся к исходной идее «каждый тип знает, как сериализовать себя». Если конкретные классы Data и Links также необходимо сериализовать в других контекстах (не как члены Response), реализуйте для них функции сопоставления с образцом и вызывайте их из переопределенных функций-членов. В противном случае сериализация может происходить непосредственно в этих функциях-членах.

class Data
{
public:
    virtual ~BaseData() = default;
    
    void deserialize(const Poco::JSON::Object::Ptr &payload) = 0;
    Poco::JSON::Object::Ptr to_json() const = 0;
    
    //...
};

class ConcreteData
{
public:
    ~BaseData() override = default;
    
    void deserialize(const Poco::JSON::Object::Ptr &payload)
    {
        // ...
    }
    
    Poco::JSON::Object::Ptr to_json() const
    {
        // ...
    }
}

// ------

Poco::JSON::Object::Ptr Response::to_json() const
{
    // ...
    
    auto serializedData = data->to_json();
    
    // ...
}
person besc    schedule 04.07.2020