четверг, 22 августа 2013 г.

[prog.c++] Расскажу о простом рефакторинге своего старого C++ного кода

Сделал я когда-то систему сериализации данных для C++. В ее тестах с давнишних времен завалялся unit-тест для проверки обработки различных типов STL-ных контейнеров. Начинался код этого теста всего с нескольких классов. Да еще во времена, когда основным компилятором под Windows был Visual C++ 6.0, в котором поддержка STL-я, да и нормального C++, была так себе. В общем, для проверки std::vector был написан класс. Потом из него посредством копипасты был сделан класс для проверки std::list, потом std::deque и т.д. А со временем этот тест стал проверять еще и разные варианты сериализации этих контейнеров. Так что количество очень похожих друг на друга классов удвоилось, а общий объем исходного файла вырос до 1200 строк. При очередной попытке впихнуть в него проверку еще одного варианта пришлось честно сознаться самому себе, что это уже ни в какие рамки не лезет, нужно браться за серьезный рефакторинг. Получилось, на удивление, быстро и не больно :) Об этом и будет нижеследующий рассказ. Пояснений много не потребуется, но вот фрагменты кода будут более-менее объемные. Поэтому кому действительно интересно, милости прошу под кат.

Особенностью моей разработки является то, сериализовать можно типы, унаследованные от специального базового класса. Эти объекты в качестве атрибутов могут использовать STL-ные контейнеры. Поэтому, чтобы проверить сериализацию конкретного типа контейнера, нужно создать собственный класс, который будет содержать этот контейнер в качестве атрибута. Вот как выглядел такой класс для проверки std::vector в исходном варианте:

class vector_type_t:
   public oess_2::stdsn::serializable_t
{
      OESS_SERIALIZER( vector_type_t )

   public:
      vector_type_t() {}

      std::ostream &
      dump( std::ostream & to ) const
      {
         to << "{ ";
         std::for_each( m_vector.begin(), m_vector.end(), show_item( to ) );
         to << "}";
         return to;
      }
      
      void
      fill_in()
      {
         for(int i = 0 ; i < 5 ; ++i)
            m_vector.push_back(i);
      }
      
   private:
      struct   show_item
      {
         std::ostream & m_to;
         show_item( std::ostream & to ) : m_to( to ) {}

         void
         operator()( const oess_2::long_t & a )
         {
            m_to << a << " ";
         }
      };

      std::vector< oess_2::long_t > m_vector;
};

std::ostream &
operator<<( std::ostream & to, const vector_type_t & what )
{
   return what.dump( to );
}

В принципе, для C++ начала 2002-го здесь не так уж много лишнего. Метод dump сделан для того, чтобы не френдить оператор сдвига объекта в std::ostream. А вспомогательный тип show_item -- это функтор для отображения элементов контейнера в поток. Наверное, в dump-е можно было бы обойтись обычным for-ом, внутри которого уже заниматься форматированием каждого элемента контейнера и получилось бы компактнее. Но тогда я был под сильным влиянием STL-ных итераторов и функций вроде std::for_each :)

Класс для тестирования std::list-а является слегка модицифированной копией вышеприведенного кода:

class list_type_t:
   public oess_2::stdsn::serializable_t
{
      OESS_SERIALIZER( list_type_t )
   
   public:
      list_type_t() {}

      std::ostream &
      dump( std::ostream & to ) const
      {
         to << "{ ";
         std::for_each( m_list.begin(), m_list.end(), show_item( to ) );
         to << "}";
         return to;
      }

      void
      fill_in()
      {
         m_list.push_back("one");
         m_list.push_back("two");
         m_list.push_back("three");
         m_list.push_back("four");
         m_list.push_back("five");
      }

   private:
      struct   show_item
      {
         std::ostream & m_to;
         show_item( std::ostream & to ) : m_to( to ) {}

         void
         operator()( const std::string & a )
         {
            m_to << "'"<< a << "' ";
         }
      };

      std::list< std::string >   m_list;
};

std::ostream &
operator<<( std::ostream & to, const list_type_t & what )
{
   return what.dump( to );
}

Аналогичным образом выглядели и классы для std::deque, std::set/multiset, std::map/multimap. Главные различия были в методах fill_in (где производилось наполнение контейнера) и в описании самого контейнера (где-то элементом контейнера были long-и, где-то string-и, где-то short-ы и пр.). В методе operator() вспомогательных классов show_item так же были некоторые различия в форматировании разных типов значений, но их оказалось всего три варианта.

Итак, стало очевидно, что главный объем дублирующегося кода связан с отображением значения тестового объекта в std::ostream (это отображение нужно было для проведения сравнения объектов и отладочных печатей). Поэтому самым первым шагом стало удаление отдельных operator<< для каждого тестового типа. Вместо этого был введен общий базовый класс dumpable_t, который был добавлен к каждому тестовому типу в качестве еще одного базового:

class dumpable_t
{
   public :
      virtual std::ostream &
      dump( std::ostream & to ) const = 0;
};

inline std::ostream &
operator<<( std::ostream & to, const dumpable_t & what )
{
   return what.dump(to);
}

class vector_type_t :
   public oess_2::stdsn::serializable_t,
   public dumpable_t
{
      OESS_SERIALIZER( vector_type_t )
...
class list_type_t:
   public oess_2::stdsn::serializable_t,
   public dumpable_t
{
      OESS_SERIALIZER( list_type_t )
...

Соответственно, вместо одного operator<< на каждый тип теперь оказался всего один такой оператор на все тестовые типы, т.к. все они стали наследниками dumpable_t.

Следующим шагом было избавление от дублирования внутри всех методов dump и отказ от уникальных классов-функторов show_item для каждого тестового класса. В итоге тестовые классы стали выглядеть так:

class vector_type_t :
   public oess_2::stdsn::serializable_t,
   public dumpable_t
{
      OESS_SERIALIZER( vector_type_t )
   public:
      vector_type_t() {}

      virtual std::ostream &
      dump( std::ostream & to ) const
      {
         return show_content(m_vector, to);
      }

      void
      fill_in()
      {
         for(int i = 0 ; i < 5 ; ++i)
            m_vector.push_back(i);
      }

   private:
      std::vector< oess_2::long_t > m_vector;
};

И, в качестве еще одной демонстрации, тестовый класс list_type_t:

class list_type_t:
   public oess_2::stdsn::serializable_t,
   public dumpable_t
{
      OESS_SERIALIZER( list_type_t )
   public:
      list_type_t() {}

      virtual std::ostream &
      dump( std::ostream & to ) const
      {
         return show_content( m_list, to );
      }
      
      void
      fill_in()
      {
         m_list.push_back("one");
         m_list.push_back("two");
         m_list.push_back("three");
         m_list.push_back("four");
         m_list.push_back("five");
      }
      
   private:
      std::list< std::string >   m_list;
};

Соответственно, вся "магия" по обработке содержимого контейнера оказалась во внешней функции show_content. Очевидно, что это шаблонная функция, т.к. она должна работать с разными типами контейнеров и разными типами значений внутри контейнеров. Впрочем, тип контейнера ей совершенно безразличен, т.к. ей от контейнера нужны только методы begin и end.

Реализация show_content тривиальна. А так же компактна, поскольку в C++11 уже есть лямбда-функции:

templateclass C >
std::ostream &
show_content( const C & what, std::ostream & to )
{
   to << "{ ";

   std::for_each( what.begin(), what.end(),
         [&to]( const typename C::value_type & a ) {
            print_value( to, a );
         } );

   to << "}";

   return to;
}

Внутри лямбды используется вспомогательная шаблонная функция print_value, которая имеет два варианта и одну дополнительную специализацию под разные типы элементов контейнеров (в итоге получаются те три варианта форматирования значений, которые были в первоначальной реализации unit-теста):

templateclass A >
void
print_value( std::ostream & to, const A & a )
{
   to << a << " ";
}

// Специализация для строк, которые должны обрамляться апострофами.
template<>
void
print_value( std::ostream & to, const std::string & a )
{
   to << "'" << a << "' ";
}

// Вариант для элементов map/multimap, где есть ключ и значение.
templateclass K, class V >
void
print_value( std::ostream & to, const std::pair<K, V> & a )
{
   to << "(" << a.first << ":" << a.second << ") ";
}

Вот, собственно и все. Раньше вся эта кухня занимала порядка 1000 строк исходного кода. Сейчас, даже после добавления туда еще одного нового тестового класса, около 600.

Кстати говоря. Насколько я знаю, в C++11 требуется точно указывать тип аргумента для лямбда-функции. В приведенном здесь примере этот вопрос был решен за счет использования typename C::value_type, т.к. по стандарту все нужные мне STL-ные контейнеры определяют имя value_type. Если бы мне пришлось столкнуться с контейнером у которого есть что-то вроде итераторов, но нет value_type (например, с самодельным контейнером, который внутри себя использует обычный массив, а его методы begin и end возвращают голые указатели), то пришлось бы что-то придумывать.

К счастью, на этот случай в C++11 есть такая классная штука, как decltype. С ее использованием код выглядел бы так:

std::for_each( what.begin(), what.end(),
      [&to]( decltype(*what.begin()) & a ) {
         print_value( to, a );
      } );

Признаюсь, что я не хотел делать вариант show_content, который бы получал не контейнер целиком, а два итератора, из чисто эстетических соображений. Использование show_content в приведенном выше варианте выглядит гораздо компактнее. Однако, если бы в тестах потребовалось проверять сериализацию, скажем, обычных C-шных массивов, то потребовался бы именно такой вариант show_content. Причем, за счет введенных в стандарт C++11 функций std::begin и std::end работа с STL-ными контейнерами и C-шными массивами выглядела бы одинаковой. И тогда бы внутри show_content точно бы пришлось задействовать decltype. Вот самодостаточный пример, проверенный под GCC 4.7.1 и 4.8.1 (под VS 2010 работать не будет, т.к. там не поддерживаются initializer_list-ы, а VS 2012 у меня нет):

#include <algorithm>
#include <iostream>
#include <iterator>
#include <list>
#include <map>
#include <string>
#include <vector>

templateclass T >
void
print_value( std::ostream & to, const T & a )
{
   to << a << " ";
}

templateclass K, class V >
void
print_value( std::ostream & to, const std::pair<K, V> & a )
{
   to << "(" << a.first << ":" << a.second << ") ";
}

templateclass I >
std::ostream &
show_content( I begin, I end, std::ostream & to )
{
   to << "{ ";

   std::for_each( begin, end,
         [&to]( decltype(*begin) & a ) {
            print_value( to, a );
         } );

   return to << "}" << std::endl;
}

int
main()
{
   std::vector< int > v = { 123 };
   show_content( std::begin(v), std::end(v), std::cout );

   std::list< std::string > l = { "one""two""three" };
   show_content( std::begin(l), std::end(l), std::cout );

   std::map< std::string, std::string > m =
         { { "one""I" }, { "two""II" } };
   show_content( std::begin(m), std::end(m), std::cout );

   std::string a[] = { "One""Two""Three" };
   show_content( std::begin(a), std::end(a), std::cout );
}

Резюмируюя хочу сказать вот что. В C++ со времен VS 2003 можно писать и просто, и компактно (это если говорить о платформе Windows, под GNU это время наступило чуть пораньше). Для этого нужно даже не столько включать мозги, сколько бороться с собственной ленью и не поддаваться искушению копипасты. Новый C++11 дает разработчику еще больше возможностей для этого. Что не может не радовать. А C++14 обещает сделать наши волосы густыми и шелко нашу работу еще более простой и приятной ;)

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