Провел на работе очередной code review. Парочкой впечатлений хочу поделиться. Предупреждаю сразу, примеров кода будет много ;)
Во-первых, очень удивляет меня, насколько сильно система отступов и переносов строк влияет на читабельность большого объема кода (порядка нескольких тысяч строк). Когда просматриваешь только одну функцию, то непривычный для тебя стиль переносов не играет никакой роли. Ну написано так, а не как у тебя. Ну и ладно.
Но потом в действие вступает закон больших чисел. Усталость ли, замыливание ли глаз или рассеивание внимания, но из-за непривычного оформления изучать чужой код становиться гораздо сложнее. Целые куски просто выпадают из поля зрения.
Например, вот в этом случае:
return get_valid_path( filename.substr( 0, filename.size() - get_name_of_file( filename ).size() ) + "sqlldr" ); |
В чем здесь проблема для меня? В том, что не понятно, что именно является параметрами substr – выражение сложное и глазу не за что в нем зацепиться. Вот как этот же фрагмент написал бы я:
return get_valid_path( filename.substr( 0, filename.size() - get_name_of_file( filename ).size() ) + "sqlldr" ); |
В этом случае filename.substr() и “sqlldr” находятся на одном уровне вложенности и это подсказывает мне, что они являются равноправными частями одного выражения.
Еще напрягают вот такие выражения:
static bool check( const msg_check_report_wait_t * msg ) { return ( (msg->m_operation_id != c_unknown_operation_id) && (msg->m_uuid != c_unknown_uuid) ); } |
Я бы переписал это чуть компактнее:
static bool check( const msg_check_report_wait_t * msg ) { return msg->m_operation_id != c_unknown_operation_id && msg->m_uuid != c_unknown_uuid; } |
И совсем удивительным для меня оказалось то, что тяжело читаются куски кода, в которых в if-ах единичные операторы все равно обрамляются фигурными скобками. Т.е. когда вот такой код:
int detect_actual_code( int result, int code, const std::string & type_result ) { if ( result == c_more_one_answer ) { // Более одного ответа. return e_fatal_error; } else { // Если был результат 2204050, то // код ответа не может быть 0, т.е. все плохо. if ( (type_result == "2204050") && (code == 0) ) { return e_task_error; } if ( code != e_all_correct ) { return e_task_error; } } return code; } |
встречается слишком часто, то воспринимается он гораздо хуже, чем вот такой:
int detect_actual_code( int result, int code, const std::string & type_result ) { if ( result == c_more_one_answer ) // Более одного ответа. return e_fatal_error; else { // Если был результат 2204050, то // код ответа не может быть 0, т.е. все плохо. if ( (type_result == "2204050") && (code == 0) ) return e_task_error; if ( code != e_all_correct ) return e_task_error; } return code; } |
Уж не знаю, за счет чего. Может из-за того, что снижается объем полезной информации в коде, может еще из-за чего-то. Но эффект имеет место быть. Причем, повторюсь, такой эффект как раз силен в случае, когда за короткое время нужно просмотреть большой объем кода.
Во-вторых, если при первом взгляде на функцию кажется, что ее реализация слишком “мудрена”, то с очень высокой степенью вероятности эта функция будет работать неправильно. И что существует множество простых способов ее улучшить.
Вот пример такой функции. Ее задача – сформировать идентификатор из 11 цифр на основе текущей даты и времени. Оригинальный вариант:
std::string make_timestamped_uid() { // Текущие msec в формат '000' с лидирующими нулями. std::string msec = cpp_util_2::slexcast( ACE_OS::gettimeofday().msec() % 1000 ); // Число чисмолов в '000'. const int c_count_000 = 3; while ( msec.size() < c_count_000 ) { msec = "0" + msec; } // Секунды с какого-то года в виде строки плюсуем к '000'. std::string uid = cpp_util_2::slexcast( ACE_OS::gettimeofday().sec() ) + msec; // Должно быть число в 11 цифр с лидирующими нулями. const int c_count_digits = 11; // Добиваем если не хватило. while ( uid.size() < c_count_digits ) { uid = "0" + uid; } // Обрезаем старшее если получилось больше, чем 11 символов. if ( uid.size() > c_count_digits ) { uid = uid.substr( uid.size() - c_count_digits ); } return uid; } |
Первое, что мне бросилось в глаза – это два идентичных цикла для формирования нужного количества лидирующих нулей. Это действие сразу же нужно было бы выносить в отдельную inline-функцию. Которая бы при этом работала не через while, а через метод basic_string::insert.
Второе – это двойной вызов ACE_OS::gettimeofday() для получения текущего времени. Вполне достаточно было бы и одного.
Потом я задался вопросом – а зачем мы сначала выравнивали значения msec и uid до точных размеров, а потом обрабатывали превышение этого размера? Что-то в этом не то. Без этого вполне можно было бы обойтись. Ведь можно же сразу получать из даты/времени целочисленные значения в нужных диапазонах, а потом просто использовать стандартные средства C++ для форматирования.
Update. Три следущих абзаца оказались гнусным поклепом с моей стороны, поскольку я не заметил операции взятия остатка от деления на 1000. Что, к сожалению доказывает, что в переусложненном коде сложно разбираться
Тут-то всплыла ошибка, допущенная разработчиком make_timestamped_uid. Функция ACE_OS::gettimeofday() возвращает объект ACE_Time_Value. Который является парой <sec,usec>. Где sec – это текущая секунда от 1 января 1970, а вот usec – это микросекунда в рамках текущей секунды. Т.е. точное время – это (sec+usec).
Разработчик думал, что обращение к msec() – возвращает usec/1000, т.е. миллисекунды в рамках текущей секунды. Но ACE_Time_Value::msec() возвращает <sec,usec> преобразованные в миллисекунды (т.е. sec*1000+usec/1000). И даже не это значение, а ту его часть, которая помещается в unsigned long. Получается, что переменная msec всегда будет иметь длину больше трех символов. И что значение этой переменной будет иметь косвенное отношение к текущей миллисекунде.
Счетчик секунд от 1 января 1970 года сейчас – это десять десятичных цифр. К которым добавляется всего один символ из msec. Т.к. этот символ первый (т.е. самый старший разряд), то он будет одним и тем же для очень длительного временного интервала. Значит, несколько последовательных вызовов make_timestamped_uid в течении одной секуды (даже с интервалом в сотни миллисекунд) будет приводить к генерации одного и того же идентификатора. Т.е. функция не будет делать то, на что она была расчитана.
А ведь можно было написать все гораздо проще. Имхо, вот так:
std::string make_timestamped_uid() { const ACE_Time_Value now = ACE_OS::gettimeofday(); char buf[ 16 ]; // Поскольку нам нужно строго 11-ти значное значение, // в котором три самых младших разряда будут занимать // миллисекунды, то от счетчика секунд оставляем // только 8 младших разрядов. const unsigned long s = now.sec() % 100000000ul; // Микросекунды должны быть преобразованы в миллисекунды. // Остаток от деления на 1000 для гарантии в случае, если // usec возвращает значение больше 1000000. const unsigned long m = ( now.usec() / 1000 ) % 1000; std::sprintf( buf, "%08lu%03lu", s, m ); return buf; } |
Если повыбрасывать комментарии, и промежуточные константы s и m, то можно вообще тремя-четырьмя строками обойтись. (Те, кому не нравится sprintf, могут получить то же самое через std::ostringstream, setfill и setw).
Вспоминается замечательный афоризм (за авторством, C.A.R.Hoare):
Есть два способа создания программы. Можно сделать ее настолько простой, что в ней совершенно очевидно нет ошибок. А можно сделать настолько сложной, что в нет совершенно очевидных ошибок.
Ну и в качестве бонуса для тех, что смог дочитать до этого места. В обсуждавшейся выше функции make_timestamped_uid() есть просто смешной фрагмент:
// Число чисмолов в '000'. const int c_count_000 = 3; |
5 комментариев:
Приятно почувствовать себя вумным: когда читал код то сразу вызвали фэ и повторение цикла с "пришиванием" нуля, и двойной вызов, и возмутило - так зачем "пришивали" 0 чтобы потом обрезать? Проверить низя чтоли заранее?
Такой код у меня вызывает ассоциацию с речью пьяного - мысль то есть, но блуждает, прерывается и язык заплетается.
И риторический вопрос - неужто от такого "пьяного" кода избавит "хаскел"?
А насколько я понимаю - именно такой код пишется чаще всего, и именно такой код в итоге и приводит и к тормознутости и заграбастыванию памяти программ, и к трудно уловимым глюкам.
Поди заметь логическую ошибку в речи пьяного, среди просто ошибок "заплетающегося" языка...
Есть два способа создания программы. Можно сделать ее настолько простой, что в ней совершенно очевидно нет ошибок. А можно сделать настолько сложной, что в нет совершенно очевидных ошибок.
Мне эти слова тоже вспоминаются, когда пытаюсь с помощью Visual Studio собрать что-нибудь с сайта sourceforge.net. И всегда удивляюсь: ну почему же разработчики столь невнимательны?
Вот недавно пытался собрать библиотеки доступа к OLAP серверу Palo (http://palo.svn.sourceforge.net/viewvc/palo/molap/client/3.X/). Сколько же я убил времени на это занятие.
1. Проект не собирается Express версией Visual Studio, так как зависит от Active Template Library (ATL), не включенной в Express редакцию VS. В документации об этом ни гугу.
2. Каждый проект зависел от библиотек boost, при этом по какой-то причине директории с заголовочными файлами и библиотеками не были прописаны в проекте. Более того, при сборке наткнулся на баг самой библиотеки boost (http://sourceforge.net/apps/trac/easystroke/ticket/21), пришлось патчить заголовочный файл. Нужные модули boost для компиляции подбирались экспериментальным образом (смторим на ошибки компиляции, добавляем нужные библиотеки, перекомпилируем и т.д.). Хорошо еще сам boost не пришлось собирать,для Windows есть готовый инсталлятор с www.boostpro.com.
3. Разработчики не указывают зависимости. В результате получается неприятная игра: получаем ошибку "не найден заголовочный файл", ищем в google, от какой библиотеки он может быть, скачиваем, опять компилим, и так до посинения.
Итог моей свистопляски такой: 10 часов убитого времени, из 8 проектов скомпилировались 6, хорошо еще, что остальные 2 проекта мне были не нужны. Такое ощущение, что разработчики не тестируют свои поделки, или специально усложняют сборочную систему до такой степени, что без поллитра не разберешься;-)
Как было бы хорошо, если вместе с исходными кодами поставлялись бы все нужные заголовочные файлы и библиотеки именно тех версий, которые используют разработчики. Пусть даже мне пришлось бы скачать 200-300 мегабайт. Или в качестве альтернативного решения инсталлятор, который сам бы выкачивал нужные файлы с помощью svn/git/и т.д. и копировал в нужные директории.
Увы, не делают разработчики решений вида "1-Click Compile Solution". Так чтобы открыл проект, запустил компиляцию и пошел пить кофе, а по возвращении получил бы готовый собранный проект. Вот она, защита от конкуренции в open-source разработках - сделать все как можно сложнее. А лучше так, чтобы во всем мог разобраться только сам разработчик. Жаль:-(
>Увы, не делают разработчики решений вида "1-Click Compile Solution". Так чтобы открыл проект, запустил компиляцию и пошел пить кофе, а по возвращении получил бы готовый собранный проект.
Думаю, что здесь очень много чисто C++ной специфики. Поскольку в C++ нет ни общепринятой системы сборки (аналога ant-а для Java), ни общепринятой системы дистрибуции библиотек (аналогов maven2, rubygems). Плюс к тому, в C++ есть специфические режимы компиляции (debug, release, static lib, dynamic lib, static rtl, shared rtl, rtti enabled/disabled). Плюс под каждой OS свой собственный формат распространения пакетов (в BSD-шных свои, в Linux-овых свои).
В языках, которые появились позже C++ (в первую голову, Java) с этим, говорят, гораздо лучше. По крайней мере я слышал очень лестные отзывы об Java-вской Maven2. В Ruby есть очень удобная RubyGems. В Scala сразу какой-то sbaz сделали, чтобы таких проблем не иметь.
Плюс к тому, в C++ есть специфические режимы компиляции (debug, release, static lib, dynamic lib, static rtl, shared rtl, rtti enabled/disabled).
Однако многие проекты выкладывают собранные бинарники, которые собираются с использованием конкретных режимов компиляции, версий библиотек и т.д. Хотелось бы иметь хоть какой-то полностью работоспособный и собираемый вариант в качестве шаблона, отталкиваясь от которого можно легко и просто получать собственные решения. Тогда можно было бы без опасений экспериментировать со сборкой, при необходимости возвращаясь к работоспособному варианту.
Выкладываются, но тогда не всегда можно подружить разные подпроекты в рамках своего проекта.
Еще с бинарниками убивает то, что они зачастую отдаются не под ту версию компиляторов, которая у меня используется. Скажем, последние версии ICU компилируются, afaik, под VS2008, а мы еще на VS2003 сидим.
Так что C++ в этом отношении просто каменный век.
Отправить комментарий