Как стать автором
Обновить

Комментарии 44

НЛО прилетело и опубликовало эту надпись здесь

Спасибо за ссылку! Мне не приходилось использовать данную библиотеку, но, похоже, она предоставляет более функциональный интерфейс чем libclang (я говорю именно о C API). Обязательно добавлю в пост.

Двойственное ощущение от статьи.
Прежде всего не покидает ощущение, что цель «а давайте поиспользуем новых фич» была превыше «а давайте это все будет читаемо и расширяемо».
Почему-то в статье никак не упомянуты существующие реализации, хотя бы обзорно как-то, например 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.


А зачем тогда виртуальные функции-члены, если у Вас каждый тип конкретный?

Каждый — это какой?

Каждое использование типа, наследующего от 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…
Как по мне, то я представляю концептуально правильное решение так:
  1. Используем позднее связывание и указатель на базовый класс — деструктор должен быть виртуальным, хотя бы на всякий случай.
  2. Используем полиморфизм времени компиляции. Базовый класс тут не нужен, современный C++ позволяет использовать концепты и т.д.
  3. Нам нужен полиморфирм, но мы хотим за счет позднего связывания избежать распухания кода — теоретически можно сделать статический защищенный деструктор, но как мы будем это гарантировать. Минимум — тут нужен комментарий, документирующий наше решение и ограничения на способ использования. И этот комментарий становится «неотъемлемой частью интерфейса»

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

А что за случай? Конкретно? Удаление по указателю на базовый класс? Так у Вас даже код не скомпилируется, ведь деструктор — protected.


Используем полиморфизм времени компиляции. Базовый класс тут не нужен, современный C++ позволяет использовать концепты и т.д.

Соглашусь, можно было использовать концепты. Но я захотел наследование.


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

Без комментариев.

А что значит «несогласован»? Интерфейс предоставляет библиотека, и она же предоставляет другие компоненты, которые работают с этим интерфейсом. Если пользователь решил использовать интерфейс не по назначению — вопросы к пользователю.


Почему деструктор не 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 то никакой динамической диспетчеризации не будет. Это слово специально добавили в язык, чтобы был виден маркер "внимание: тут динамика".

Конечно, Вы правы, спасибо, я слишком поверхносно знаю Rust и сомневался, как тут написать правильно. Действительно dyn &T, а не & dyn T? Но общая идея, надеюсь, была понятна?

Общая идея — понятна.


Я к тому, что fn f(s: impl T) это скорее запись для ленивых нормального варианта fn f<Type: T>(s: Type). До сих пор не понимаю, зачем в язык добавили еще один вариант записи одного и того же (видимо, чтобы всех запутать), но вот так.

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

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

Рано или поздно кому-то взбредет в голову создать производный класс через new и присвоить указателю на базовый класс. Как он удалится?

Его нельзя удалить по указателю на базовый класс, будет ошибка компиляции "destructor is not accessible". Таким образом разработчик библиотеки сообщает вам что он не хочет чтобы вы использовали интерфейс ISerializable таким образом.


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


class IDestructableSerializable: public ISerializable 
{
public:
    virtual ~IDestructableSerializable() = default;
};

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

Основная проблема с описанным сериализатором в том, что при сериализации он теряет семантику данных и заточен под конкретный бинарный формат данных. Что если сериализовать необходимо в разные форматы, например, бинарный, json, xml и тот же protobuf? Кроме того, есть еще одна важная задача: если сериализуемые данные достаточно сложны, необходимо обеспечивать сохранение и восстановление ссылок между объектами, а это не такая тривиальная задача, как может показаться.

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

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

Похоже на описание DTO


Что если сериализовать необходимо в разные форматы, например, бинарный, json, xml и тот же protobuf?

Спасибо, что указали на данную проблему, это интересно.


Кроме того, есть еще одна важная задача: если сериализуемые данные достаточно сложны, необходимо обеспечивать сохранение и восстановление ссылок между объектами

Это уже больше на репликацию похоже.

Похоже на описание DTO

Да, это именно они и есть.

Это уже больше на репликацию похоже.

Нет, почему же, все зависит от сложности данных. В одной из компаний, где я работал, нужно было сохранить и прочитать довольно сложную структуру данных в файл. Ссылки сохранялись преобразованием указателей в аналоги реляционных идентификаторов и обратно на лету, но пляски начинались в тот момент, когда надо было восстановить дополнительное состояние объекта согласно ссылкам, которые он содержит. А еще объекты могли содержать огромные массивы данных, которые сохранялись в отдельные blob-секции в файле, и их тоже надо было дополнительно распаковать, но только после того, как объект прочитан из файла. В результате мы использовали двойной проход при загрузке: при де-сериализации объекты и ссылки восстанавливались, а второй проход запускал инициализацию. Я сейчас точно не вспомню, в чем была основная загвоздка, но в данном случае описанный вами интерфейс не позволял инициализировать всю структуру без второго прохода.

Предполагаю, что DTO смогли бы решить эту проблему, но кодовая база на тот момент была уже такая большая (коду было 25 лет, он писался с начала 90-х), что никто бы не одобрил подобный масштабный рефакторинг. С оглядкой на этот опыт, я бы сейчас отнес реализацию ISerializable в классах бизнес-логики скорее к анти-паттернам. DTO хоть и выглядят громоздко и требуют какое-то количество boilerplate кода, впоследствии более гибкие и удобные в использовании.

Спасибо за информацию! Не хотите об это более подробно рассказать, с техническими деталями? Лично мне было бы очень интересно почитать.

Исходные данные — это иерархическая сложная CAD-структура, объекты могут ссылаться один на другого не только в вертикальных направлениях дерева иерархии, но еще и горизонтально. Геометрические объекты могут состоять из сотен тысяч элементов (точки, грани) или иметь большой объем (текстуры), которые часто удобнее сохранять в отдельном бинарном виде. Поэтому файл сохранения состоит из секций. Головная секция-заголовок содержит описание всей структуры в семантическом формате (например, json), а данные объектов хранятся в отдельных blob-секциях. Получившиеся файлы секций пакуются в zip-архив, который дополнительно шифруется.

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

Я не знаю, что еще могу добавить не вдаваясь в подробности, подпадающие под NDA, поэтому вы лучше задавайте конкретные вопросы, а я попробую на них ответить.
И главное то, что это быстрее, чем декомпозировать в какое-либо хранилище, а потом собирать, так?
С учетом гетерогенности данных и отсутствия 20 (уже почти 30) лет назад систем для их эффективного хранения, да, это быстрее и удобнее. Плюс компании нужен был надежный проприетарный формат для хранения данных на стороне клиента, потому что облаков тогда тоже не было.

Хорошо, спасибо! Если у меня позже возникнут вопросы, можно будет написать Вам в лс?

Да, конечно, пишите, с удовольствием отвечу

Спасибо!

А зачем везде писать
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;
}
Но для простых типов зачем так писать.
Можно возразить, что лучше всё записывать единообразно, пусть и длиннее. Но тут количество существующего кода со старым синтаксисом перевесит любые новые попытки.
Я тоже не знаю, зачем так писать для простых типов и предпочитаю более короткую запись, но это дело вкуса, и я предпочитаю не придираться, разве что это мой код-ревью :-D
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории