Сделал я когда-то систему сериализации данных для 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 уже есть лямбда-функции:
template< class 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-теста):
template< class 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, где есть ключ и значение. template< class 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> template< class T > void print_value( std::ostream & to, const T & a ) { to << a << " "; } template< class K, class V > void print_value( std::ostream & to, const std::pair<K, V> & a ) { to << "(" << a.first << ":" << a.second << ") "; } template< class 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 = { 1, 2, 3 }; 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 обещает сделать наши волосы густыми и шелко нашу работу еще более простой и приятной ;)
Комментариев нет:
Отправить комментарий