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

Добавлю еще, что одним из мощных стимулов к написанию данной статьи стало то, что очень часто на глаза попадаются объемные флеймыобсуждения на тему «ООП не нужно» и, особенно, «шаблоны-дженерики на практике почти никогда не нужны». Мне, как далеко не молодому программисту, начинавшему в 1990-ом как раз с инструментов, в которых не было ни ООП, ни шаблонов-дженериков, странно сталкиваться с подобными точками зрения. Но, чем дальше, тем чаще с ними сталкиваешься. Особенно со стороны приверженцев новых языков программирования, вроде Go или Rust-а.

Сложно сказать, чем это вызвано. Может быть людей перекормили ООП (а это так и было)… Может быть задачи за несколько минувших десятилетий сильно поменялись (а это так и есть)… Может быть и просто «вот и выросло поколение»… Как бы то ни было, можно попробовать на примере из реальной жизни показать, что все не так однозначно ©.

Итак, о чем пойдет речь?

Upd. Предварительный disclaimer. Статья о C++. Примеры кода приведены на C++. Поэтому, если вы не переносите C++ или не знаете C++ в достаточной степени, то пожалуйста, воздержитесь от комментариев вида «простыни нечитабельного кода». Конструктива такие комментарии не несут и вряд ли они будут кому-либо полезными.

Суть задачи


Мы недавно выпустили новую версию своего OpenSource-фреймворка, где добавили новую фичу под названием deadletter handlers. И эту новую фичу нужно было протестировать. Совсем несложными тестами. В одном тесте нужно было проверить, что программист может установить deadletter handler, в другом — что пользователь может отменить deadletter handler.

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

А поскольку в SObjectizer код, реализующий deadletter handler-ы, активно использует шаблоны, то для проверки правильности работы шаблонов нужно было в тестах покрыть все разумные сочетания этих условий.

Поясню для тех, кто не очень хорошо знаком с особенностями C++: если, например, шаблонная функция нигде в коде не вызывается, то некоторые компиляторы (вроде старых версий Visual C++) даже не всегда проверяют исходный код этой функции на наличие элементарных ошибок. Поэтому для того, чтобы быть уверенным, что с реализацией шаблона все нормально, нужно, чтобы шаблонные функция или класс были проинстанциированы явно или неявно. Надежнее всего этого достичь через явное использование шаблонной функции/класса в тесте.

Возможное решение «в лоб»


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

Так, для сочетания MPMC-mbox+обычное иммутабельное сообщение+указатель на метод получился бы такой класс:

class mpmc_message_pfn_test_case_t final : public so_5::agent_t {
   state_t st_test{this};
   const so_5::mbox_t mbox_;

   class test_message final : public so_5::message_t {};

public:
   mpmc_message_pfn_test_case_t(context_t ctx)
      :  so_5::agent_t{std::move(ctx)}
      ,  mbox_{so_environment().create_mbox()}
   {}

   virtual void so_define_agent() override {
      this >>= st_test;
      so_subscribe_deadletter_handler(mbox_,
         &mpmc_message_pfn_test_case_t::on_deadletter);
   }

   virtual void so_evt_start() override {
      so_5::send<test_message>(mbox_);
   }

public:
   void on_deadletter(mhood_t<test_message>) {
      so_deregister_agent_coop_normally();
   }
};

А для сочетания MPMC-mbox+обычное иммутабельное сообщение+лямбда-функция потребовался бы очень похожий класс, но с небольшими изменениями:

class mpmc_message_lambda_test_case_t final : public so_5::agent_t {
   state_t st_test{this};
   const so_5::mbox_t mbox_;

   class test_message final : public so_5::message_t {};

public:
   mpmc_message_lambda_test_case_t(context_t ctx)
      :  so_5::agent_t{std::move(ctx)}
      ,  mbox_{so_environment().create_mbox()}
   {}

   virtual void so_define_agent() override {
      this >>= st_test;
      so_subscribe_deadletter_handler(mbox_, [this](mhood_t<test_message>) {
            so_deregister_agent_coop_normally();
         });
   }

   virtual void so_evt_start() override {
      so_5::send<test_message>(mbox_);
   }
};

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

class direct_mutable_message_lambda_test_case_t final : public so_5::agent_t {
   state_t st_test{this};

   class test_message final : public so_5::message_t {};

public:
   direct_mutable_message_lambda_test_case_t(context_t ctx)
      :  so_5::agent_t{std::move(ctx)}
   {}

   virtual void so_define_agent() override {
      this >>= st_test;
      so_subscribe_deadletter_handler(so_direct_mbox(),
         [this](mutable_mhood_t<test_message>) {
            so_deregister_agent_coop_normally();
         });
   }

   virtual void so_evt_start() override {
      so_5::send<so_5::mutable_msg<test_message>>(*this);
   }
};

Надеюсь, идея понятна.

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

Другой путь


Поскольку у меня есть жесткий пунктик по поводу копипасты в коде и неспроста, то был выбран другой путь. Через использование шаблонов. И, в одном месте, шаблона шаблонов. К чему затем еще добавилось и наследование с полиморфизмом. Но начнем с простого теста, в котором достаточно только шаблонов.

Простой тест (используются только шаблоны)


Итак, у нас есть три фактора, которые нужно комбинировать между собой. Два из них — тип mbox-а и тип сообщения/сигнала — легко представить в виде параметра шаблона. Не просто с третьим: чем именно реализуется deadletter handler — указателем на функцию или лямбдой. Ну и ладно. Цель же не в том, чтобы избавиться от копипасты совсем. Цель в том, чтобы обойтись самым необходимым ее минимумом.

Поэтому было сделано два шаблонных класса. Первый для случая, когда deadletter handler реализуется указателем на метод:

template< typename Mbox_Case, typename Msg_Type >
class pfn_test_case_t final : public so_5::agent_t
{
   const Mbox_Case m_mbox_holder;

   state_t st_test{ this, "test" };

public:
   pfn_test_case_t( context_t ctx )
      :  so_5::agent_t( std::move(ctx) )
      ,  m_mbox_holder( *self_ptr() )
   {}

   virtual void
   so_define_agent() override
   {
      this >>= st_test;

      so_subscribe_deadletter_handler( m_mbox_holder.mbox(),
            &pfn_test_case_t::on_deadletter );
   }

   virtual void
   so_evt_start() override
   {
      so_5::send<Msg_Type>( m_mbox_holder.mbox() );
   }

private:
   void
   on_deadletter( mhood_t<Msg_Type> )
   {
      so_deregister_agent_coop_normally();
   }
};

Второй — для случая лямбда функции:

template< typename Mbox_Case, typename Msg_Type >
class lambda_test_case_t final : public so_5::agent_t
{
   const Mbox_Case m_mbox_holder;

   state_t st_test{ this, "test" };

public:
   lambda_test_case_t( context_t ctx )
      :  so_5::agent_t( std::move(ctx) )
      ,  m_mbox_holder( *self_ptr() )
   {}

   virtual void
   so_define_agent() override
   {
      this >>= st_test;

      so_subscribe_deadletter_handler( m_mbox_holder.mbox(),
            [this](mhood_t<Msg_Type>) {
               so_deregister_agent_coop_normally();
            } );
   }

   virtual void
   so_evt_start() override
   {
      so_5::send<Msg_Type>( m_mbox_holder.mbox() );
   }
};

Можно заметить, что эти шаблонные классы очень похожи на те примеры кода, которые были показаны выше. Только для простого теста, проверяющего установку deadletter handler-а, теперь достаточно всего двух классов. А не десяти.

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

class test_message final : public so_5::message_t {};

class test_signal final : public so_5::signal_t {};

Ну и при инстанциировании шаблонов pfn_test_case_t и lambda_test_case_t будут использоваться test_message, so_5::mutable_msg<test_message> и test_signal. С этим все просто.

А вот с параметром Mbox_Case немного сложнее (хотя, если C++ вы знаете хорошо, то ничего сложного там нет вообще). Этот параметр определяет, какой именно mbox должен использоваться в тестовом случае: MPMC-mbox, который следует создавать специально, или же direct_mbox, который уже есть у каждого агента.

В наших тестах в качестве Mbox_Case используются два очень простых типа:

class direct_mbox_case_t
{
   const so_5::agent_t & m_owner;
public :
   direct_mbox_case_t( const so_5::agent_t & owner )
      :  m_owner(owner)
   {}

   const so_5::mbox_t &
   mbox() const noexcept { return m_owner.so_direct_mbox(); }
};

class mpmc_mbox_case_t
{
   const so_5::mbox_t m_mbox;
public:
   mpmc_mbox_case_t( const so_5::agent_t & owner )
      :  m_mbox( owner.so_environment().create_mbox() )
   {}

   const so_5::mbox_t &
   mbox() const noexcept { return m_mbox; }
};

Экземпляр класса direct_mbox_case_t сохраняет у себя ссылку на агента для того, чтобы в своем методе mbox() возвращать direct_mbox этого агента. А экземпляр класса mpmc_mbox_case_t у себя в конструкторе создает экземпляр MPMC-mbox-а и возвращает ссылку на него в своем методе mbox().

Получается, что когда, например, класс pfn_test_case_t параметризуется direct_mbox_case_t, то в pfn_test_case_t::m_mbox_holder хранится ссылка на сам экземпляр pfn_test_case_t и при вызове m_mbox_holder.mbox() возвращается direct_mbox() самого агента.

А когда pfn_test_case_t параметризуется mpmc_mbox_case_t, то в pfn_test_case_t::m_mbox_holder лежит экземпляр отдельного MPMC-mbox, который создается при конструировании экземпляра pfn_test_case_t.

Ну и тоже самое получается для lambda_test_case_t.

Итак, мы получаем возможность создавать вот такие сочетания для тестовых случаев:

pfn_test_case_t<direct_mbox_case_t, test_message>;
pfn_test_case_t<direct_mbox_case_t, so_5::mutable_msg<test_message>>;
pfn_test_case_t<direct_mbox_case_t, test_signal>;
pfn_test_case_t<mpmc_mbox_case_t, test_message>;
pfn_test_case_t<mpmc_mbox_case_t, test_signal>;
lambda_test_case_t<direct_mbox_case_t, test_message>;
lambda_test_case_t<direct_mbox_case_t, so_5::mutable_msg<test_message>>;
...


Еще по поводу параметра Mbox_Case. У нас используются классы. Хотя можно было параметризовать тестовые классы и функцией, которая бы возвращала mbox_t. Т.е. можно было бы сделать так:

using mbox_maker_t = so_5::mbox_t (*)(const so_5::agent_t &);

so_5::mbox_t mpmc_mbox_maker(const so_5::agent_t & agent) {
   return agent.so_environment().create_mbox();
}

so_5::mbox_t direct_mbox_maker(const so_5::agent_t & agent) {
   return agent.so_direct_mbox();
}

template< mbox_maker_t Mbox_Case, typename Msg_Type >
class pfn_test_case_t final : public so_5::agent_t
{
   const so_5::mbox_t m_mbox;

   state_t st_test{ this, "test" };

public:
   pfn_test_case_t( context_t ctx )
      :  so_5::agent_t( std::move(ctx) )
      ,  m_mbox( Mbox_Case(*self_ptr()) )
   {}
...
};
... pfn_test_case_t<direct_mbox_maker, test_message>;
... lambda_test_case_t<mpmc_mbox_maker, test_signal>;

Принципиально бы это ничего не поменяло. Но первое, что пришло в голову — это именно классы, они и пошли в реализацию.

А где же шаблон шаблонов?


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

env.introduce_coop([](so_5::coop_t coop) {
      coop.make_agent<
         pfn_test_case_t<direct_mbox_case_t, test_message>>();
   });
env.introduce_coop([](so_5::coop_t coop) {
      coop.make_agent<
         pfn_test_case_t<direct_mbox_case_t, so_5::mutable_msg<test_message>>>();
   });
env.introduce_coop([](so_5::coop_t coop) {
      coop.make_agent<
         pfn_test_case_t<direct_mbox_case_t, test_signal>>();
   });

Но лучше ввести вспомогательную шаблонную функцию:

template<
   typename Mbox_Case,
   typename Msg_Type,
   template <class, class> class Test_Agent >
void
introduce_test_agent( so_5::environment_t & env )
{
   env.introduce_coop( [&]( so_5::coop_t & coop ) {
         coop.make_agent< Test_Agent<Mbox_Case, Msg_Type> >();
      } );
}

И потом уменьшить себе количество работы и сделать код создания тестов более читаемым:

introduce_test_agent<direct_mbox_case_t, test_message, pfn_test_case_t>(env);
introduce_test_agent<direct_mbox_case_t, so_5::mutable_msg<test_message>, pfn_test_case_t>(env);
introduce_test_agent<direct_mbox_case_t, test_signal, pfn_test_case_t>( env );

И вот introduce_test_agent уже и есть тот самый «шаблон шаблонов», т.е. шаблонная функция, одним из шаблонных параметров которой является другой шаблон.

Более сложный тест, в котором потребуется наследование с полиморфизмом


Разобранный выше тест был очень простым, там достаточно было отослать всего одно сообщение, поэтому и тестовые агенты в нем были простыми. А вот следующий тест, проверяющий то, что пользователь может отменить deadletter handler, уже посложнее. Для того, чтобы разобраться с ним для начала посмотрим, как должен работать агент для тестового случая (для простоты берем пока только обычное иммутабельное сообщение test_message):

  • агент должен стартовать, повесить deadletter handler для сообщения test_message;
  • после этого агент должен отослать себе сообщение test_message для проверки того, что deadletter handler действительно есть;
  • когда test_message приходит в deadletter handler, агент должен отменить deadletter handler, после чего он отсылает себе test_message еще раз, а следом специальный сигнал finish;
  • если агент еще раз получает test_message в deadletter handler, значит тест провален;
  • если агент получает только finish без повторного test_message, значит тест успешно пройден и работу агента можно прекращать.

Опять же, чтобы было проще разобраться с последующим кодом, покажем, как бы выглядел агент, написанный «в лоб» для конкретного тестового случая. Самый простой вариант, с direct_mbox-ом и обычным иммутабельным сообщением test_message:

struct finish final : public so_5::signal_t {};

class test_case_specific_agent_t : public so_5::agent_t
{
   state_t st_test{ this };

   int m_deadletters{ 0 };

   void deadletter_handler(mhood_t<test_message>)
   {
      ensure_or_die( 0 == m_deadletters, "m_deadletters must be 0" );
      ++m_deadletters;

      so_unsubscribe_deadletter_handler<test_message>(so_direct_mbox());
      so_5::send<test_message>(*this);
      so_5::send<finish>(*this);
   }

public:
   test_case_specific_agent_t( context_t ctx )
      :  so_5::agent_t(std::move(ctx))
   {}

   virtual void so_define_agent() override
   {
      this >>= st_test;

      so_subscribe_deadletter_handler(
            so_direct_mbox(), 
            &test_case_specific_agent_t::deadletter_handler);

      st_test.event([this](mhood_t<finish>) {
            so_deregister_agent_coop_normally();
         });
   }

   virtual void so_evt_start() override
   {
      so_5::send<test_message>(*this);
   }
};

Т.е. есть счетчик входов в deadletter_handler (атрибут m_deadletters с нулевым начальным значением). Внутри deadletter_handler() этот счетчик проверяется на ноль и инкрементируется. Если deadletter_handler() будет вызван повторно, то тест провалится.

Метод deadletter_handler отсылает два сообщения. Первое должно быть проигнорировано. Второе должно привести к завершению работы теста (подписка на сигнал finish идет в so_define_agent).

Ну и самый первый экземпляр test_message отсылается в so_evt_start. Т.е. при старте агента.

Однако, это не шаблонный класс. Да еще и заточен под конкретный тестовый сценарий. Как сделать из него шаблон, который можно было бы параметризовать двумя параметрами Mbox_Case и Msg_Type, как в предыдущем простом тесте?

Очевидное решение (не самое интересное)


Очевидное решение было бы в том, чтобы взять классы pfn_test_case_t и lambda_test_case_t из простого теста и просто переделать каждый из них под новую логику работы.

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

Менее очевидное решение (с наследованием и полиморфизмом)


Итак, нам нужно вынести общие части из pfn_test_case_t и lambda_test_case_t в какой-то общий класс. И т.к. классы агентов в SObjectizer-е должны наследоваться от so_5::agent_t, то скорее всего этот общий класс будет базовым и для pfn_test_case_t, и для lambda_test_case_t.

А вот один ли это будет класс? Давайте посмотрим.

Можно обратить внимание на то, что в демонстрационном test_case_specific_agent_t есть как куски кода, которые зависят от параметров шаблона, так и куски кода, которые от параметров шаблона не зависят. Скажем, наличие состояния st_test и обработчик сигнала finish от параметров шаблона не зависят. А вот установка и отмена deadletter handler-а, отсылка тестового сообщения — зависят.

Это дает нам возможность разбить общий код на две части. Первая часть не будет шаблонной. Для реализации этой части нам потребуется вот такой класс:

class nontemplate_basic_part_t : public so_5::agent_t
{
protected:
   state_t st_test{ this, "test" };

   int m_deadletters{ 0 };

   void
   actual_deadletter_handler()
   {
      ensure_or_die( 0 == m_deadletters, "m_deadletters must be 0" );
      ++m_deadletters;

      do_next_step();
      so_5::send<finish>(*this);
   }

   virtual void
   do_next_step() = 0;

public:
   nontemplate_basic_part_t( context_t ctx )
      :  so_5::agent_t( std::move(ctx) )
   {}

   virtual void
   so_define_agent() override
   {
      this >>= st_test;

      st_test.event( [this](mhood_t<finish>) {
            so_deregister_agent_coop_normally();
         } );
   }
};

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

Однако, уже в nontemplate_basic_part_t нужно выполнить два действия, которые зависят от Msg_Type — это отмена deadletter handler-а и отсылка еще одного экземпляра Msg_Type. Внутри nontemplate_basic_part_t мы знаем, где и когда эти действия должны быть выполнены, но не можем их выполнить.

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

Наследником же, в котором do_next_step() определяется, будет уже шаблонный класс следующего вида:

template< typename Mbox_Case, typename Msg_Type >
class template_basic_part_t : public nontemplate_basic_part_t
{
protected:
   const Mbox_Case m_mbox_holder;

   virtual void
   do_next_step() override
   {
      so_drop_deadletter_handler< Msg_Type >( m_mbox_holder.mbox() );

      so_5::send< Msg_Type >( *this );
   }

public:
   template_basic_part_t( context_t ctx )
      :  nontemplate_basic_part_t( std::move(ctx) )
      ,  m_mbox_holder( *self_ptr() )
   {}

   virtual void
   so_evt_start() override
   {
      so_5::send<Msg_Type>( m_mbox_holder.mbox() );
   }
};

Тут мы уже видим привычный трюк с атрибутом m_mbox_holder типа Mbox_Case. А так же мы видим реализации виртуальных методов do_next_step (отмена deadletter handler-а и отсылка второго экземпляра Msg_Type) и so_evt_start (отсылка первого экземпляра Msg_Type).

Получается, что nontemplate_basic_part_t и template_basic_part_t уже содержат 95% нужной тестовому агенту функциональности. Осталось всего ничего — сделать pfn_test_case_t и lambda_test_case_t в которых бы устанавливался deadletter handler нужного вида.

Вот так это будет выглядеть:

template< typename Mbox_Case, typename Msg_Type >
class pfn_test_case_t final : public template_basic_part_t< Mbox_Case, Msg_Type >
{
   using base_type_t = template_basic_part_t< Mbox_Case, Msg_Type >;

public:
   using base_type_t::base_type_t;

   virtual void
   so_define_agent() override
   {
      base_type_t::so_define_agent();

      this->so_subscribe_deadletter_handler( this->m_mbox_holder.mbox(),
            &pfn_test_case_t::on_deadletter );
   }  

private:
   void
   on_deadletter( so_5::mhood_t<Msg_Type> )
   {
      this->actual_deadletter_handler();
   }
};

template< typename Mbox_Case, typename Msg_Type >
class lambda_test_case_t final : public template_basic_part_t< Mbox_Case, Msg_Type >
{
   using base_type_t = template_basic_part_t< Mbox_Case, Msg_Type >;

public:
   using base_type_t::base_type_t;

   virtual void
   so_define_agent() override
   {
      base_type_t::so_define_agent();

      this->so_subscribe_deadletter_handler( this->m_mbox_holder.mbox(),
            [this](so_5::mhood_t<Msg_Type>) {
               this->actual_deadletter_handler();
            } );
   }
};

Тут просто классическое наследование с перекрытием виртуального метода предка для того, чтобы расширить его поведение: в so_define_agent() сперва вызывается so_define_agent() из базового класса, после чего устанавливается deadletter handler должного вида.

Вот в итоге и получается старое доброе ООП, с наследованием (реализации) и полиморфизмом. Да еще и обильно сдобренное обобщенным программированием.

Disclaimer


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

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

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

Что-то вроде заключения


ООП — это всего лишь инструмент. Не религия, не болезнь. Всего лишь инструмент. Где-то он уместен, где-то нет. Скажем, если вам нужно делать сложную и большую библиотеку, то ООП может вам пригодиться. Если делаете небольшое и несложное приложение, то может и не пригодиться. А может и наоборот. Тут все зависит как от предметной области, так и от ваших знаний и опыта. Ну и от религиозных пристрастий, конечно же.

Аналогично и с обобщенным программированием. Это всего лишь инструмент. Вас никто не заставляет его использовать, когда в этом нет нужды.

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

Ну а C++, при всех своих недостатках, хорош тем, что позволяет использовать и то, и другое. Да еще в самых разных сочетаниях. Другой вопрос, как научиться использовать и то, и другое (и еще кучу возможностей C++) по месту и в меру. Но это уже совсем другая история… :)

Комментарии (30)


  1. IonianWind
    12.02.2018 20:38
    +1

    А тесты на тесты писать нужно?


    1. eao197 Автор
      12.02.2018 21:18

      Я, обычно, каждый новый тест провоцирую на «падение». Если создаешь в тесте условие, при котором тест должен упасть, а он не падает, значит сам тест нужно тестировать. Где-то один раз из 10-15 это приходится делать.


    1. Zordhauer
      13.02.2018 09:46

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


      Но, это скорее допустимое исключение, чем норма… стремиться надо к минимализму и понятности… просто в реальном мире не всегда удаётся в первой итерации декомпозиции получить простейшие сущности


  1. smer44
    12.02.2018 21:37
    -1

    тут не только тесты на тесты которые будут сложнее тестов на сорс, а деобфускатор нужен
    это образец обфускации С++ на гнольий ибо

    • имя класса пишем с маленькой буквы, чтобы их не отличать
    • злоупотребление_подчёркиванием
    • в дополнение к этому, активно используем в названиях цифры типа so_1_2_5
    • в дополенние к этому, обфускация переноса на новую строку делает визуально плохоразличимыми обьявления конструкторов и функций, также отлично обфусцировано лябмда- выражение и не понятен перегруз this >>= st_test; без контекста
    • Первый для случая, когда deadletter handler реализуется указателем на метод, Второй — для случая лямбда функции. Для этого без припудривания мозгов, в одном тестовом классе пишутся два метода для теста первого и второго, возможные общие моменты выносятся в отдельные методы
    • разумеется для полного использования возможностей шаблонов С++, на них нужно написать не только шаблоны внутри шаблонов, а ещё и тьюринг машину, лисп машину и правила пролог. При этом, назначение тестов в том чтобы они были максимально простыми, очевидными, лишёнными подводных камней и по возможности понятными без контекста

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


    1. eao197 Автор
      12.02.2018 22:13

      В вашем комментарии прекрасно все, от начала и до конца.
      Хочется, однако, прояснить один момент: вы когда нибудь в глаза видели названия классов, методов и функций из стандартной библиотеки С++? Ну или из Boost-а?


      1. smer44
        12.02.2018 22:52
        +1

        стандартная либа исторически состоит из шедевров типа ptr_ioblablabla_t вместо говорящих за себя названий, но на то она и стандартная чтоб все знали эти названия. В бусте там матрицы, вектора или обьекты в Python'e имеют математическое наименование. У тебя же тест класс, который есть чисто ООП сущность, опять же, тест должен быть максимально понятный на случай чтобы другому человеку было удобно читать без углубления что такое например this >>= st_test;. То есть заместо so_5 MsgTypeTestTemplated
        Но тут и много других обфускаций кроме названий.
        На практике такой код, тем более со сложными темплейтами, это вырвиглаз, и как правильно написал человек выше требует написания теста к тесту, в частности чтоб потестить темплейты, который будет сложнее самого теста.
        А провоцирование падения может вызвать ложноположительное исполнение без падения.


        1. smer44
          12.02.2018 23:00

          то есть, тьфу, наоборот, ложноотрицательное падение когда падает по другим причинам а те по тем что думаешь ты а визуально сообщение об ошибке будет такое же


          1. eao197 Автор
            13.02.2018 08:50

            Может давайте поговорим не об абстрактных «ложноотрицательных падения», а о более приземленных вещах. Вот, например, берем тест для so_unsubscribe_deadletter_handler из статьи. Пишем его нормальный вариант, запускаем. Получаем успешный результат теста.

            Потом берем, и меняем

               virtual void
               do_next_step() override
               {
                  so_drop_deadletter_handler< Msg_Type >( m_mbox_holder.mbox() );
            
                  so_5::send< Msg_Type >( *this );
               }
            на
               virtual void
               do_next_step() override
               {
            //      so_drop_deadletter_handler< Msg_Type >( m_mbox_holder.mbox() );
            
                  so_5::send< Msg_Type >( *this );
               }
            Запускаем еще раз. И мы можем оказаться в двух ситуациях.
            1. Тест «упал». Значит есть вероятность, что тест был написан правильно.

            2. Тест «не упал». Значит в тесте 100% есть ошибка.

            Соответственно, если в результате провоцирования неправильной работы теста (путем комментирования отдельных фрагментов, добавления дополнительных операций, изменения параметров операций и т.д.) возникают ситуации №2, то, как по мне, это гораздо лучше, чем полный отказ от попыток проверки корректности работы тестов.

            А вы как предлагаете проверять корректность тестов?


        1. eao197 Автор
          12.02.2018 23:05

          Чем дальше, тем интереснее.
          А можно еще вопрос: а вы на C++ давно программируете? И вообще: программируете ли вы на C++?


        1. 0xd34df00d
          14.02.2018 01:25

          А чего там в темплейтах тестировать в этом случае?


      1. g_DiGGeR
        12.02.2018 23:13
        +1

        Эти имена явно не образец для подражания для кода, который будут поддерживать.


        1. eao197 Автор
          13.02.2018 09:51
          -1

          Интересно, а какие имена вызывают наибольшие нарекания?


    1. Ritorno
      12.02.2018 23:32

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


      1. 0xd34df00d
        14.02.2018 01:26

        Единственное на мой взгляд не совсем читаемое тут — имена. ну_не_люблю_я_с_подчеркиваниями, а буст и STL уже примелькались как-то. В приведённых шаблонах (а статья именно о них) нет ничего мозговыносящего, нечитаемого, сложного или неподдерживаемого, на мой взгляд.


        1. eao197 Автор
          14.02.2018 08:49

          Касательно имен и читаемости я бы отметил три аспекта.

          1. Удачность и понятность конкретных имен классов/методов. Например, pfn_test_case_t для меня, как для прошедшего через венгерскую нотацию, вполне нормальное и читабельное имя. Но вот для тех, кто помоложе и про венгурку не слышал, буквосочетание pfn может быть совершенно нечитаемым. Поэтому и возник вопрос, но он повис в воздухе без ответа. Что наводит на мысли о том, что конструктива автор комментария явно не предполагал изначально.

          2. Следование общепринятым стандартам кодирования. Например, если бы кто-то в Java или в C# коде начал использовать только snake_case, то это должно было бы вызвать нарекания у других Java/C# разработчиков, т.к. для этих языков уже есть давно устоявшиеся соглашения об именовании. Но в C++ же такого нет, в C++ вполне благополучно живут и развиваются проекты, которые используют snake_case, а так же проекты, которые используют CamelCase. И даже проекты, которые используют Camel_With_Underscores_Case. Поэтому в C++ коде претензии к стилю оформления — это чистой воды вкусовщина. На которую вообще не следовало бы обращать внимания. Но поди ж ты :(

          3. У меня лично возврат к snake_case был вполне себе обоснованным и, где-то, выстраданным решением после многих лет использования CamelCase. Так что использование snake_case — это вполне себе обдуманное и взвешенное решение. Я могу понять, что кому-то такой стиль именования не нравится. Но если snake_case мне объективно помогает, то чисто вкусовые претензии по поводу отсутствия CamelCase просто не принимаются во внимание.


          1. 0xd34df00d
            14.02.2018 09:29

            Например, pfn_test_case_t для меня, как для прошедшего через венгерскую нотацию, вполне нормальное и читабельное имя.

            Я бы тут отметил, что упоминать в каждом классе «test_case» как-то выглядит излишним. Они же и так в тестах и не будут использоваться нигде вне них, зачем это повторять?

            Поэтому в C++ коде претензии к стилю оформления — это чистой воды вкусовщина.

            С этим (в разумных пределах) я полностью согласен.


            1. eao197 Автор
              14.02.2018 11:16

              Я бы тут отметил, что упоминать в каждом классе «test_case» как-то выглядит излишним. Они же и так в тестах и не будут использоваться нигде вне них, зачем это повторять?
              Помнится, изначально казалось, что в тесте потребуется больше классов, поэтому-то и появились суффиксы _mbox_case_t и _test_case_t. Но потом выяснилось, что больше ничего и не нужно, но избавиться от префиксов в голову уже не пришло.


    1. nshmakov
      13.02.2018 00:39

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

      Кроме того, в C++ есть прекрасная штука using, которая может сделать так, чтобы интерфейсные классы внешней библиотеки начали соответствовать нужному стилю написания.

      А что касается тестов, то конечно хотелось бы, чтобы они были простыми и понятными, а главное рабочими, но зачастую остается хотеть, чтобы они просто БЫЛИ, а из списка быстро написанные, понятные, покрывающие большой процент кода, как в известном приколе-картинке, приходится выбирать 2 из 3.


      1. eao197 Автор
        13.02.2018 08:43

        Справедливости ради нужно сказать, что using не поможет с именами методов, к примеру.
        Да и тестов, которые не работают, пользу нет. Так что если тесты есть, то они должны быть рабочими.

        Другое дело, что споры о code convention начинаются тогда, когда о сути и смысле статьи сказать нечего. И уж тем более странно видеть споры о code convention для C++, в котором нет официального и общепринятого соглашения об именовании. И в котором давно и успешно сосуществуют snake_case, CamelCase и Camel_With_Underscore_Case.


    1. Antervis
      13.02.2018 10:03
      +1

      поймите меня правильно, но как иначе тестировать шаблонный код? Например, для проверки корректности работы какого-нибудь std::vector нужно прогнать тесты для:

      1. T разных размеров
      2. Тривиальных/нетривиальных T
      3. T с доступностью разных конструкторов (copy_constructible, default_constructible, move_constructible)
      4. T с доступностью разных операторов присваивания (move_assignable/copy_assignable)
      5. T с различными политиками исключений (noexcept_copyable, noexcept_constructible, noexcept_move_assignable...)
      6. overaligned T
      7. собственно, различных тестовых сценариев

      Итого бойлерплейтом получатся тысячи простых тестов. Или всё-таки лучше несколько сложных?


      1. ZyXI
        14.02.2018 01:12

        Есть ещё возможный подход: написать генератор простых тестов вместо собственно простых тестов. Оправданность зависит от того, насколько сложно отлаживать «сложные» тесты и насколько сложно создать собственно генератор, а также для чего генератор будет полезен. При правильное реализации генератор должен генерировать достаточно хорошо читаемый и хорошо отлаживаемый boilerplate.


        На C ещё был бы вариант с использованием ffi для написания тестов на языке более высокого уровня, но ни макросы ни шаблоны C++ так не протестируешь (хотя, может, clang предоставляет какое?нибудь полезное API…).


        1. 0xd34df00d
          14.02.2018 01:29

          Действительно, вместо шаблонов давайте писать и отлаживать свой генератор, особая прелесть которого в том, что это личное уникальное детище вас или вашей команды, и новому члену в нём придётся разбираться.

          clang API предоставляет, и это интересная мысль, на самом деле, но это как-то совсем хардкорненько, как по мне. Но идея интересная, спасибо, замутить что ли что-то этакое…


          1. ZyXI
            14.02.2018 01:39

            Я на C++ не пишу и не могу сказать, насколько тесты на шаблонах отлаживать сложнее кучи простых тестов на самописном генераторе, но вот генераторы я для C не раз писал по разным поводам. Если людям не нравится тесты на шаблонах, то это тоже альтернатива, а сам генератор отлаживать не так уж сложно.


        1. Antervis
          14.02.2018 07:01

          Кодогенератор усложняет код файлов проекта/скриптов сборки. И не факт что он будет проще шаблонного тестера


        1. eao197 Автор
          14.02.2018 08:52

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


          1. ZyXI
            14.02.2018 09:40

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


    1. eao197 Автор
      14.02.2018 14:28

      Очень жаль, что автор наброса бесследно исчез не пожелав ответить на конкретные вопросы. Так же жаль, что не удалось установить, программирует ли он вообще на C++ или нет (судя по высказываниям о Boost-е, smer44 от C++ очень далек). Поэтому придется разбирать наброс просто в надежде, что smer44 или кто-то из заплюсовавших его комментарий найдет возможность поговорить о конкретике.

      Итак:

      тут не только тесты на тесты которые будут сложнее тестов на сорс
      Что именно тут нуждается в тестах для тестов?
      имя класса пишем с маленькой буквы, чтобы их не отличать
      Это C++. Не Java, ни JavaScript, ни Rust, ни Go. Здесь нет общепринятого code guidelines. Кто-то использует CamelCase, кто-то snake_case, кто-то Camel_With_Underscores_Case. В мире C++ это нормально. Ненормально, когда в рамках одного исходника наблюдается «разброд и шатание», т.е. часть классов в CamelCase, часть в snake_case, часть еще как-то. Здесь этого нет. Кроме того, у имен типов есть суффикс _t. Как раз специально, чтобы их можно было отличать.
      злоупотребление_подчёркиванием
      См. предыдущий пункт.
      в дополнение к этому, активно используем в названиях цифры типа so_1_2_5
      Цифры используются только в виде so_5::. Это название пространства имен верхнего уровня, такое же, как std:: или boost::. И цифра 5 в этом названии играет важную роль. Почему so_5, а не so5 или sobjectizer_five? Потому что это дело вкуса.
      в дополенние к этому, обфускация переноса на новую строку делает визуально плохоразличимыми обьявления конструкторов и функций
      Мы уже давно используем нотацию, в которой тип возвращаемого значения описывается на предыдущей строке. Так гораздо проще видеть границы функций/методов в ситуациях вроде вот таких:
      virtual const typename Some_Long_Template_Name::some_long_nested_type_name
      first_method(...) ...
        {}
      template<typename T>
      typename std::enable_if<...some_long_condition...>::type
      second_method(...) ...
        {}

      Соответственно, для консистентности мы используем такой стиль везде. Поэтому так и выглядят те фрагменты, которые выдернуты из реального кода, а не написаны специально для статьи.
      и не понятен перегруз this >>= st_test; без контекста
      А все остальное без контекста понятно? Например, почему делается наследование от so_5::agent_t? Какую роль играют методы so_define_agent? Что именно делает so_5::send? Для чего и почему используется mhood_t<Msg_Type>? Примеры выдернуты из реального кода. И чтобы объяснить в деталях каждый кусок реального кода, придется написать статью такого же объема, если не больше. Поэтому, когда демонстрируется реальный код, а не написанный специально для статьи, всегда будет что-то, что непонятно неподготовленному читателю. Тем более, что this >>= st_test — это мелкая фоновая деталь, которая вообще не имеет отношения к основной теме статьи.
      Для этого без припудривания мозгов, в одном тестовом классе пишутся два метода для теста первого и второго, возможные общие моменты выносятся в отдельные методы
      Тут, боюсь, у комментатора вообще непонимание предмета. Суть проблемы в том, что в некий класс so_5::agent_t был добавлен набор новых методов. Один из этих методов, а именно so_subscribe_deadletter_handler, и нужно было протестировать. Этот метод должен вызываться наследниками so_5::agent_t. И so_subscribe_deadletter_handler может получать либо указатель на метод наследника, либо лямбду. Т.е. в тесте нужно создать именно наследника от so_5::agent_t и чтобы этот наследник вызвал нужный вариант so_subscribe_deadletter_handler. Сделать это можно как показано в статье. Код класса-наследника в этом случае получается очень простым. Либо, если я правильно понял, как предлагает smer44, что-то вроде:
      enum class test_variant_t { pfn, lambda };
      template<typename Mbox_Case, typename Msg_Type>
      class test_agent_t : public so_5::agent_t {
        const test_variant_t variant_;
      public:
        test_agent_t(context_t ctx, test_variant_t variant)
          : so_5::agent_t(std::move(ctx)), variant_(variant) {}
        ...
        virtual void so_define_agent() override {
          ...
          if(test_variant_t::pfn == variant_)
            so_subscribe_deadletter_handler(..., &test_agent::on_deadletter);
          else
            so_subscribe_deadletter_handler(..., [this](mhood_t<Msg_Type>) {...}
          ...
        }
      ...
      };
      Только вот этот вариант не будет проще. И ошибиться в нем как раз легче.
      разумеется для полного использования возможностей шаблонов С++, на них нужно написать не только шаблоны внутри шаблонов, а ещё и тьюринг машину, лисп машину и правила пролог.
      Чистой воды наброс. В комментариях не нуждается.
      При этом, назначение тестов в том чтобы они были максимально простыми, очевидными, лишёнными подводных камней и по возможности понятными без контекста
      Тесты к любой нетривиальной библиотеке в принципе не могут быть понятным без контекста. В принципе. Что до «очевидности», «лишенности подводных» камней, то хотелось бы увидеть конкретные претензии: что не очевидно, где подводные камни?
      при этом в коде нет таких тривиальных необходимых вещей как разделение на предворительный сеттинг окружения в до-тестовое состояние и уборка после теста.
      Еще один момент, который наводит на мысль о том, что автор не работает с C++. В статье вообще не было показано как тесты запускаются, как создается тестовое окружение, как определяется успешность или не успешность тестов. Т.е. того самого окружения, о котором говорит smer44 вообще в статье нет. Поскольку это окружение не имеет никакого отношения к предмету статьи. Статья рассказывает о том, как с помощью наследования и шаблонов сделать набор классов, которые затем будут использоваться в каком-то тестовом окружении. И все.

      Было бы очень интересно услышать пояснения или комментарии от самого smer44 или кого-то из тех, кто разделяет его точку зрения.


  1. 0xd34df00d
    14.02.2018 01:23

    Хороший пример применения шаблонов в небиблиотечном коде, спасибо!

    Не просто с третьим: чем именно реализуется deadletter handler — указателем на функцию или лямбдой. Ну и ладно. Цель же не в том, чтобы избавиться от копипасты совсем. Цель в том, чтобы обойтись самым необходимым ее минимумом.

    Ну почему, на мой взгляд тоже достаточно просто: можно добавить просто ещё один аргумент шаблона, от которого наследуется ваш класс для теста и который определяет какую-нибудь const static auto functor(), возвращающую указатель на член в одной реализации и лямбду в другой.


    1. eao197 Автор
      14.02.2018 08:59

      Хороший пример применения шаблонов в небиблиотечном коде, спасибо!
      Вам спасибо!
      можно добавить просто ещё один аргумент шаблона
      Можно было бы. Но это явно бы сделало реализацию теста более сложной. Тем более, что нам нужно ограничиваться тем подмножеством C++11, которое реализовано в MSVC++12.0. Там фокус с static auto functor() не так-то просто провернуть.

      А в описанной реализации небольшая копипаста дает простоту и независимость от ограничений старых C++ компиляторов.


      1. 0xd34df00d
        14.02.2018 09:30
        +1

        Тем более, что нам нужно ограничиваться тем подмножеством C++11, которое реализовано в MSVC++12.0.

        А, сочувствую :(