Комментарии 44
Прежде всего не покидает ощущение, что цель «а давайте поиспользуем новых фич» была превыше «а давайте это все будет читаемо и расширяемо».
Почему-то в статье никак не упомянуты существующие реализации, хотя бы обзорно как-то, например Boost.Serialization, Protobuf, QDataStream, etc, и хотя бы какое-то сравнение, чем наш «велосипед» лучше/хуже их (если честно, вообще не вижу по какому пункту он лучше. Ну ладно, по пункту «не надо тащить буст», объективно, лучше.).
Так же вызывают вопросы многие детали реализации.
// for support a std::deque we forced to use a std::distance()
const auto len = static_cast<std::uint16_t>(
std::distance(value.cbegin(), value.cend()));
std::size — не то?
std::ostream по дефолту не использует исключения. Неплохо бы проверять вообще результаты записи, например.
auto stream_reader<T, void>::read(std::istream& is, T& value)
Почему не аллоцировать весь контейнер сразу, например, чтобы не делать временные значения и push_back?
stream_reader<std::string>::write
Писатель читает, читатель пишет :) все логично.
const auto len = static_cast<typename stream_writer::size_type>(value.size());
os.write(reinterpret_cast<const char*>(&len), sizeof(len));
совместимость сериализуемого size_t между 32 и 64 платформами?
Может быть учет ByteOrder в сериализации?
Да не, и так сойдет.
В общем ваш пример приложения хорош для объяснения как можно применять новые средства языка, но не для полноценной переносимой сериализации.
Protobuf
Не всем подойдёт. Например, он не очень удобный, когда объекты, с которыми работает программа, имеют много методов.
Т.к. pb с наследованием не очень дружит, то обращение к внутренним его полям будет представлять собой не очень приятное зрелище.
Плюс у него довольно жирный рантайм даже в минимальной версии, без рефлексии.
Возможно, более подходящим в этом случае будет flatbuffers, но это опять же не избавит от необходимости писать обёртки над fb классами.
QDataStream
Нужен Qt, что не сказать что лучше буста.
stream_reader<std::string>::write
Спасибо, что заметили, исправлю.
совместимость сериализуемого size_t между 32 и 64 платформами?
Почему Вы решили, что там будет size_t?
std::size
— не то?
А std::size
точно умеет работать с std::forward_list
?
std::ostream по дефолту не использует исключения. Неплохо бы проверять вообще результаты записи, например.
Проверять каким образом? Количество прочитанных/записанных байт возвращается. Маску для исключений пользователь может установить вне функции чтения/записи.
Может быть учет ByteOrder в сериализации?
Писал об этом в самом начале.
а давайте поиспользуем новых фич
Из новых фич здесь только концепты. Поправьте меня, если я не прав.
Почему не аллоцировать весь контейнер сразу, например, чтобы не делать временные значения и push_back?
Я так и делаю — для sequence контейнеров. Вы же, видимо, говорите о специализации для ассоциативных контейнеров — по очевидным причинам там не получится так сделать.
Про size — ну так да, у него ж ровно такая же реализация через distance емнип.
Взято с https://en.cppreference.com/w/cpp/iterator/size:
template <class C>
constexpr auto size(const C& c) -> decltype(c.size());
Посмотрите на тип возвращаемого значения — оно выводится через size
.
Принято. А про какой именно класс Вы говорите? ISerializable
?
У него спецификатор доступа — protected
. Если бы он был public
— другое дело.
Подробнее здесь: http://www.gotw.ca/publications/mill18.htm
Guideline #4: A base class destructor should be either public and virtual, or protected and nonvirtual.
Т.е. Вы нигде не используете указатель на базовый класс и не удаляете объект производного класса через указатель на базовый?
Нет, я не удаляю объекты классов-наследников ISerializable
через указатель на ISerializable
.
А зачем тогда виртуальные функции-члены, если у Вас каждый тип конкретный?
Каждый — это какой?
Если же Вы нигде не вызываете виртуальные методы через адрес/ссылку на базовый класс, то и виртуальные, а тем более абстрактные методы не нужны, тут как раз концепты, шаблоны, CRTP и т.д. уместны.
Кстати, упомянутый Вами GOTW — как раз про чистые миксины, там, действительно, виртуальный деструктор противопоказан.
Так что либо крестик снимите, либо…
А почему Вы решили, что я создаю объекты подобным образом?
Вы говорите о следующем:
ISerializable* ptr = new UserInfo();
Разве Вы видели, чтобы я в коде таким образом использовал ISerializable
?
Простите, какой крестик я должен снять?
Ваше определение не содержит концептуальной ошибки только при одном сценарии использования, когда использовать полиморфизм времени компиляции не хочется, а полиморфизм нужен, вот и используем позднее связывание:
class User: public Iserializable {...};
void do_something(ISerializable& is);
// owning context
User user;
do_somthing(user);
Ну, может быть, так и надо, но мне представляется спорным решением, поскольку:
- Полиморфизм времени компиляции может оказаться уместнее, а тут уже vtbl и все такое...
- Нет никаких языковых гарантий, что кто-то кое-где у нас порой не сделает new/delite с указателем на базовый класс. К сожалению в C++ способа такого избежать.
Мне кажется, что это то самое inheritance, которое по словам Шона Пэрента is root cause of evil…
Как по мне, то я представляю концептуально правильное решение так:
- Используем позднее связывание и указатель на базовый класс — деструктор должен быть виртуальным, хотя бы на всякий случай.
- Используем полиморфизм времени компиляции. Базовый класс тут не нужен, современный C++ позволяет использовать концепты и т.д.
- Нам нужен полиморфирм, но мы хотим за счет позднего связывания избежать распухания кода — теоретически можно сделать статический защищенный деструктор, но как мы будем это гарантировать. Минимум — тут нужен комментарий, документирующий наше решение и ограничения на способ использования. И этот комментарий становится «неотъемлемой частью интерфейса»
Я не увидел в объявлении ISerializable ни одного из трех вариантов, на что и указал. Вот и все.
Используем позднее связывание и указатель на базовый класс — деструктор должен быть виртуальным, хотя бы на всякий случай.
А что за случай? Конкретно? Удаление по указателю на базовый класс? Так у Вас даже код не скомпилируется, ведь деструктор — protected.
Используем полиморфизм времени компиляции. Базовый класс тут не нужен, современный C++ позволяет использовать концепты и т.д.
Соглашусь, можно было использовать концепты. Но я захотел наследование.
Нам нужен полиморфирм, но мы хотим за счет позднего связывания избежать...
Без комментариев.
del
А что значит «несогласован»? Интерфейс предоставляет библиотека, и она же предоставляет другие компоненты, которые работают с этим интерфейсом. Если пользователь решил использовать интерфейс не по назначению — вопросы к пользователю.
Почему деструктор не public? А зачем ему таким быть? Хорошо, давайте его сделаем public — и будем где-то в куче хранить объекты производных классов, а что мы с ними будем делать — сериализовать и десериализовать?
Содержательно Ваш подход сводится к тому, что на Rust, например, выглядит как-то так:
struct S{};
trait T {};
impl T for S {};
fn f(s: impl T)...
Ясный и понятный прием: мы явно запрещаем компилятору мономорфизацию в пользу динамической диспетчеризации.
К сожалению, явно выразить это в C++ невозможно. И при взгляде на абстрактный класс с виртуальными методами, по сути просто интефейс. В нем мы запрещаем инстанциацию через new? Нет, все-равно можно создать через new инстанс потомка и присвоить его указателю на абстрактный класс! А этого делать нельзя! И вместо того, чтобы просто документировать ограничения на использование Вашего кода, вы гордо заявляете «Сам дурак!».
Я не преследую цели вести с Вами спор, я пытаюсь объяснить причину, по которой я решил сделать деструктор невиртуальным. И на мой взгляд, для того, чтобы это понять, не нужны не книжки Александреску (ничего против него не имею, просто миксины, как мне кажется, оттуда), не нужен Rust, статические деструкторы и все то, что Вы упоминали выше.
Проблема в том, что защищенный деструктор накладывает ограничения на пользование интерфейсом. Например, такие интерфейсы нельзя передать через shared_ptr. У нас такая необходимость возникает иногда при работе с rxcpp, где в силу большого количества копирования контролировать действительную точку удаления объекта явно невозможно.
Простите, но тут нет никакой динамической диспетчеризации. impl
— это экзистенциальный тип, он всегда конкретный.
Чтобы была динамика должно быть fn f(s: dyn &T)
или fn f(s: Box<dyn T>)
. в зависимости от того, даем мы ссылку на стековое значение или забокшенное соответственно.
Если в программе не написано dyn
то никакой динамической диспетчеризации не будет. Это слово специально добавили в язык, чтобы был виден маркер "внимание: тут динамика".
Используем позднее связывание и указатель на базовый класс — деструктор должен быть виртуальным, хотя бы на всякий случай.
Я тоже не понял вашу мысль, что значит "на всякий случай"? Использование в абстрактном базовом классе не виртуального protected деструктора — это абсолютно ок.
Его нельзя удалить по указателю на базовый класс, будет ошибка компиляции "destructor is not accessible". Таким образом разработчик библиотеки сообщает вам что он не хочет чтобы вы использовали интерфейс ISerializable таким образом.
Если вы используете эту библиотеку и ваш сценарий такой что вам надо удалять объекты ISerializable по базовому классу, вам придется сделать свой интерфейс вроде такого:
class IDestructableSerializable: public ISerializable
{
public:
virtual ~IDestructableSerializable() = default;
};
Мое впечатление что ISerializable идеоматически не слишком подходит как средство хранение объекта, это как маркер того данный объект умеет сериализоваться так что ISerializable* можно использовать как параметр в функциях которые работают с сериализацией.
Мы используем другую идею для сериализации: для каждого класса или структуры данных, который предполагается сериализовать, существует структура-двойник, который используется только для передачи данных и состоит из открытых полей, но не содержит никакой логики вообще, кроме валидации. Сериализация состоит из двух дополнительных слоев: один отвечает за преобразования и валидацию данных между классами, реализующими поведение и открытыми структурами для сохранения, и еще один слой реализует сохранение в каждый конкретный формат данных (бинарный, xml, protobuf).
Такое решение позволяет полностью отделить сериализацию от бизнес логики и в результате намного более гибкое, чем поддержка интефейсов сериализации внутри собственно бизнес логики.
существует структура-двойник, который используется только для передачи данных и состоит из открытых полей
Похоже на описание DTO
Что если сериализовать необходимо в разные форматы, например, бинарный, json, xml и тот же protobuf?
Спасибо, что указали на данную проблему, это интересно.
Кроме того, есть еще одна важная задача: если сериализуемые данные достаточно сложны, необходимо обеспечивать сохранение и восстановление ссылок между объектами
Это уже больше на репликацию похоже.
Похоже на описание DTO
Да, это именно они и есть.
Это уже больше на репликацию похоже.
Нет, почему же, все зависит от сложности данных. В одной из компаний, где я работал, нужно было сохранить и прочитать довольно сложную структуру данных в файл. Ссылки сохранялись преобразованием указателей в аналоги реляционных идентификаторов и обратно на лету, но пляски начинались в тот момент, когда надо было восстановить дополнительное состояние объекта согласно ссылкам, которые он содержит. А еще объекты могли содержать огромные массивы данных, которые сохранялись в отдельные blob-секции в файле, и их тоже надо было дополнительно распаковать, но только после того, как объект прочитан из файла. В результате мы использовали двойной проход при загрузке: при де-сериализации объекты и ссылки восстанавливались, а второй проход запускал инициализацию. Я сейчас точно не вспомню, в чем была основная загвоздка, но в данном случае описанный вами интерфейс не позволял инициализировать всю структуру без второго прохода.
Предполагаю, что DTO смогли бы решить эту проблему, но кодовая база на тот момент была уже такая большая (коду было 25 лет, он писался с начала 90-х), что никто бы не одобрил подобный масштабный рефакторинг. С оглядкой на этот опыт, я бы сейчас отнес реализацию ISerializable в классах бизнес-логики скорее к анти-паттернам. DTO хоть и выглядят громоздко и требуют какое-то количество boilerplate кода, впоследствии более гибкие и удобные в использовании.
Спасибо за информацию! Не хотите об это более подробно рассказать, с техническими деталями? Лично мне было бы очень интересно почитать.
При сохранении все указатели просто запихиваются в пронумерованый сет, и каждому объекту сопоставляется уникальный индекс. При загрузки при создании объекта индексы заменяются обратно на указатели. Поскольку в момент загрузки каждого объекта окружение еще не создано полностью, указатели отрезолвить еще нельзя, поэтому нуден второй проход. Фактически, при начальной загрузке, объекты являются DTO, а второй проход преобразует их в живые экземпляры со всеми связями, поэтому я говорил, что DTO позволил бы упростить загрузку.
Я не знаю, что еще могу добавить не вдаваясь в подробности, подпадающие под NDA, поэтому вы лучше задавайте конкретные вопросы, а я попробую на них ответить.
Хорошо, спасибо! Если у меня позже возникнут вопросы, можно будет написать Вам в лс?
auto write(...) -> size_t
еслиsize_t write(...)
меньше занимает символов и читается привычнее?читается привычнее?
Субъективно.
Извините за столь короткий ответ: мне так нравится.
class Foo
{
struct Bar {};
Bar func();
};
// А теперь можно так
Foo::Bar Foo::func() {}
// А можно и так
auto Foo::func() -> Bar {}
Но это, конечно, дело вкуса и соглашений.
template <typename TA, typename TB>
auto add(TA a, TB b) -> decltype(a+b) {
return a+b;
}
Но для простых типов зачем так писать.Можно возразить, что лучше всё записывать единообразно, пусть и длиннее. Но тут количество существующего кода со старым синтаксисом перевесит любые новые попытки.
Сериализация в C++