среда, 3 февраля 2010 г.

[comp.prog.flame] Впечатления после очередного code review

Провел на работе очередной 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 комментариев:

  1. Приятно почувствовать себя вумным: когда читал код то сразу вызвали фэ и повторение цикла с "пришиванием" нуля, и двойной вызов, и возмутило - так зачем "пришивали" 0 чтобы потом обрезать? Проверить низя чтоли заранее?

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

    И риторический вопрос - неужто от такого "пьяного" кода избавит "хаскел"?

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

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

    ОтветитьУдалить
  2. Есть два способа создания программы. Можно сделать ее настолько простой, что в ней совершенно очевидно нет ошибок. А можно сделать настолько сложной, что в нет совершенно очевидных ошибок.
    Мне эти слова тоже вспоминаются, когда пытаюсь с помощью 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 разработках - сделать все как можно сложнее. А лучше так, чтобы во всем мог разобраться только сам разработчик. Жаль:-(

    ОтветитьУдалить
  3. >Увы, не делают разработчики решений вида "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 сделали, чтобы таких проблем не иметь.

    ОтветитьУдалить
  4. Плюс к тому, в C++ есть специфические режимы компиляции (debug, release, static lib, dynamic lib, static rtl, shared rtl, rtti enabled/disabled).
    Однако многие проекты выкладывают собранные бинарники, которые собираются с использованием конкретных режимов компиляции, версий библиотек и т.д. Хотелось бы иметь хоть какой-то полностью работоспособный и собираемый вариант в качестве шаблона, отталкиваясь от которого можно легко и просто получать собственные решения. Тогда можно было бы без опасений экспериментировать со сборкой, при необходимости возвращаясь к работоспособному варианту.

    ОтветитьУдалить
  5. Выкладываются, но тогда не всегда можно подружить разные подпроекты в рамках своего проекта.

    Еще с бинарниками убивает то, что они зачастую отдаются не под ту версию компиляторов, которая у меня используется. Скажем, последние версии ICU компилируются, afaik, под VS2008, а мы еще на VS2003 сидим.

    Так что C++ в этом отношении просто каменный век.

    ОтветитьУдалить