четверг, 1 февраля 2018 г.

[prog.c++] Как я тут давеча выкрутился с тестированием...

В последние пару недель работаю с кодом, в котором активно используются циклические ссылки на динамически созданные объекты. В общих чертах там что-то вот такое:

namespace details {

class data_t : public std::enable_shared_from_this {
   struct handler_t : public std::enable_shared_from_this {
      std::shared_ptr<data_t> owner_;
      ...
   };
   std::vector<std::shared_ptr<handler_t>> handlers_;
   ...
};

/* namespace details */

templatetypename Message >
class definition_point_t {
   std::shared_ptr<details::data_t> data_;
   ...
};

Т.е. есть объект типа definition_point_t, который держит умный указатель на объект details::data_t. При этом внутри details::data_t есть контейнер умных указателей на объекты-хендлеры, каждый из хэндлеров так же содержит умный указатель на этот же details::data_t. Получается, что details::data_t не может быть уничтожен, пока у него список хендлеров не пуст. Да и сами хендлеры не могут быть уничтожены, пока есть details::data_t, который на них ссылается.

Это все означало, что ответственность за корректное уничтожение details::data_t лежит на тех типах, которые data_t используют. Это, в частности, тип definition_point_t. Соответственно, нужно было написать набор юнит-тестов, которые бы прогоняли различные сценарии использования data_t внутри definition_point_t, и проверяли, удаляется ли data_t в конце-концов или нет.

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

Самый простой способ видится в том, чтобы директивами условной компиляции снабдить details::data_t нужными для тестов потрохами. Например, чем-то вроде:

class data_t : public std::enable_shared_from_this {
   struct handler_t : public std::enable_shared_from_this {
      std::shared_ptr<data_t> owner_;
      ...
   };
   std::vector<std::shared_ptr<handler_t>> handlers_;
   ...
   #if defined(MY_UNIT_TEST_CASE)
   private:
      static std::atomic<int> live_objects_;
   public:
      static int live_objects() noexcept {
         return live_objects.load(std::memory_order_acquire);
      }
   private:
   #endif

public:
   data_t(...) {
      #if defined(MY_UNIT_TEST_CASE)
         ++live_objects_;
      #endif
   }

   #if defined(MY_UNIT_TEST_CASE)
   ~data_t() {
      --live_objects;
   }
   #endif
};

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

Следующий способ, который можно было бы рассмотреть -- это возможность создать своего наследника для details::data_t и затем передать в definition_point_t фабрику, которая должна использоваться для создания объектов details::data_t. Что-то вроде:

namespace details {

class data_t : public std::enable_shared_from_this {
   struct handler_t : public std::enable_shared_from_this {
      std::shared_ptr<data_t> owner_;
      ...
   };
   std::vector<std::shared_ptr<handler_t>> handlers_;
   ...
};

using data_factory_t = std::shared_ptr<data_t>(*)();

std::shared_ptr<data_t> default_data_factory() { return std::make_shared<data_t>(); }

/* namespace details */

namespace my_test_cases {
   class definition_point_private_iface_t;
/* namespace my_test_cases */

templatetypename Message >
class definition_point_t {
   friend class my_test_cases::definition_point_private_iface_t;

   std::shared_ptr<details::data_t> data_;
   ...

   // Private constructor. Will be availiable only for definition_point_private_iface_t.
   definition_point_t(
      ..., // Some mandatory arguments.
      details::data_factory_t factory )
      : data_(factory())
   {...}

public:
   // Public constructor. Will use the default factory.
   definition_point_t(
      ... /* Some mandatory arguments. */ )
      :  definition_point_t(..., details::default_data_factory) {}
};

У definition_point_t появляется закрытый конструктор, который я смогу использовать в своих unit-тестах. Это позволит мне делать что-то вот такое:

namespace my_test_cases {

class testing_data_t : public details::data_t {
   static std::atomic<int> live_objects_;
public:
   ~testing_data_t() {
      --live_objects_;
   }
   static int live_objects() noexcept {...}
   static std::shared_ptr<data_t> factory() {
      return std::make_shared<testing_data_t>();
   }
};

definition_point_t<some_type> dp =
      definition_point_private_iface_t::create<some_type>(
            ..., // Some mandatory args.
            testing_data_t::factory);

/* namespace my_test_cases */

Т.е. создается наследник details::data_t с нужной мне функциональностью. Затем за счет использования специального закрытого интерфейса я могу создавать объекты definition_point_t подсовывая им свою фабрику. Эта фабрика вместо исходных details::data_t будет создавать testing_data_t. И уже в testing_data_t я могу делать то, что мне нужно для тестов.

Проблема в этом подходе состоит в том, что у details::data_t должен быть виртуальный деструктор. А по замыслу у details::data_t вообще не должно быть виртуальных методов и, соответственно, не должно быть таблицы виртуальных методов. Поэтому вариант с фабрикой не подходит.

И в итоге я остановился на том, чтобы добавить в шаблонный класс definition_point_t еще один параметр шаблона -- тип для data_t. Т.о. definition_point_t стал выглядеть как-то так:

template<
   typename Message,
   typename Data = details::data_t >
class definition_point_t {
   std::shared_ptr<Data> data_;
   ...
public:
   definition_point_t(
      ... /* Some mandatory arguments. */ )
      :  data_(std::make_shared<Data>())
         ...
   {}
   ...
};

И это дает мне возможность в unit-тестах создавать расширенные версии data_t:

namespace my_test_cases {

class testing_data_t : public details::data_t {
   static std::atomic<int> live_objects_;
public:
   ~testing_data_t() {
      --live_objects_;
   }
   static int live_objects() noexcept {...}
};

definition_point_t<some_type, testing_data_t> dp(... /* Some mandatory args. */);
REQUIRE(1 == testing_data_t::live_objects());
dp.do_something();
REQUIRE(0 == testing_data_t::live_objects());

/* namespace my_test_cases */

И это работает без всяких виртуальных функций.

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

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

Комментариев нет: