четверг, 17 июня 2010 г.

[prog] Возвращаясь к спору о важности борьбы с ошибками

Продолжение темы, начатой несколько дней назад. Расскажу о том, почему спор с лисперами в лице тов.archimag и тов.vsevolod оставил у меня плохие впечатления.

Отправной точкой стало заявление archimag-а:

vshabanov> ты не очень себе представляешь, от каких ошибок может защитить система типов

archimag> Просто не верю в сказки. Unit-тесты кроме улучшения дизайна код и изменения стиля разработки также ещё способствуют и предотвращению появления ошибок. Зачем нужно что-то ещё "в обычном программировании" мне не понятно.

Тут я привел archimag-у два примера: добавление в язык Eiffel механизма обеспечения т.н. void safety и планируемое добавление в C++0x ключевого слова noexcept. И попросил archimag-а объяснить, как же с помощью unit-тестов обеспечить такие же гарантии, как два этих изменения в системах типов языков Eiffel и C++. Archimag не ответил, ссылаясь на то, что он не понимает, зачем все это вообще нужно, что у него подобных ошибок никогда не было и т.д. Хотя ответ тут очевиден – unit-тесты в принципе не могут дать гарантии отсутствия конкретного типа ошибок.

Поскольку я это заварил, то расскажу от каких именно ошибок данные нововведения в Eiffel и C++ защищают. Примеры, для простоты, будут приводить на Java-подобном псевдоязыке.

Суть void safety в том, что на уровне языка обеспечивается отсутствие обращения к объектам по нулевым ссылкам. Что для языков вроде Eiffel, Java, C# и т.д. даже важнее, чем для C++. Поскольку в этих языках любой объект ссылочного типа должен быть создан через new. А раз new вызывает программист, то программист легко может забыть это сделать. Откуда и порядочное количество исключений NullPointerException в Java.

Представьте, что у вас есть класс с несколькими полями и несколькими конструкторами. В каждом конструкторе вам следует инициализировать ссылочные поля:

class Demo
   {
      SomeReferenceType firstField;
      SomeAnotherReferenceType secondField;
      ...
      YetAnotherReferenceType lastField;

      public Demo() {
         this.firstField = new SomeReferenceType();
         this.secondField = new SomeAnotherReferenceType();
         ...
         this.lastField = new YetAnotherReferenceType();
      }

      public Demo(BunchOfInitializationParams params) {
         this.firstField = new SomeReferenceType(params./*что-то-там*/);
         this.secondField = new SomeAnotherReferenceType(params./*что-то-там*/);
         ...
         this.lastField = new YetAnotherReferenceType(params./*что-то-там*/);
      }

      public Demo(AnotherInitializationParams params) {
         this.firstField = new SomeReferenceType(params./*что-то-там*/);
         this.secondField = new SomeAnotherReferenceType(params./*что-то-там*/);
         ...
         this.lastField = new YetAnotherReferenceType(params./*что-то-там*/);
      }
   }

Чем больше атрибутов будет в вашем классе, тем больше шансов, что какой-то из них вы забудете проинициализировать. Такой атрибут получит значение null и первое же обращение к нему приведет к NullPointerException. Особенно часто подобные ошибки у меня возникают при сопровождении старого кода – вводишь новый атрибут, в нескольких местах его инициализацию добавляешь, а где-то забываешь. А потом хоп! И при каком-то наборе входных данных приложение падает.

Так вот void safety позволяет компилятору бить вас по рукам при попытке обращения к ссылке (указателю), которая не была проинициализирована (или может быть null-ссылкой по замыслу разработчика).

Лично я вижу здесь большое преимущество перед unit-тестами. Хорошо, когда есть unit-тесты, покрывающие 100% исходного кода. Такие unit-тесты позволят быстро выловить null-ссылку. Но как часто мы имеем такое покрытие тестами? Как дорого оно обходится при разработке? А при сопровождении? Теперь сравните с тем, что компилятор вообще не дает нам обращаться к нулевым ссылкам. Т.е. совсем.

Теперь по поводу noexcept. Как я понимаю, это воплощение в C++0x того, что когда-то в моем блоге уже обсуждалось. Если noexcept будет нормально проверяться компилятором в compile time, то с помощью noexcept будет гораздо проще писать безопасный по отношению к исключениям код. В первую очередь, в C++ можно будет получать гарантию не бросающих исключений функций swap(). Но есть и еще одна, не менее важная штука: облегчение написания обработчиков исключений. Например, пишем мы блок catch и должны вызывать в нем функцию cleanup для очистки ресурсов. Как понять, должны ли мы писать catch так:

private void cleanup() {
   cleanFirstResource();
   cleanSecondResource();
   ...
}
try {
   /* Какие-то действия */
}
catch(SomeException x) {
   cleanup();
   /* Остальные действия по обработке исключения */
}

или же нам нужно писать так:

try {
   /* Какие-то действия */
}
catch(SomeException x) {
   try {
      cleanup();
   } catch {
      /* Сделать все равно ничего не можем, поэтому
       * просто выбрасываем возникшее исключение. */
   }
   /* Остальные действия по обработке исключения */
}

Если мы вызовем cleanup как в первом варианте, то что делать при сопровождении, когда кто-то разрешит cleanFirstResource бросать исключение?

Так вот noexcept как раз защищает нас в этой ситуации. Стоит объявить cleanup как noexcept и компилятор уже не должен позволить вызывать в ней функции без noexcept. Поэтому, если кто-то модифицирует cleanFirstResource и начнет бросать оттуда исключения, то наш cleanup попросту не будет скомпилирован.

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

После неудачного общения в ЖЖ тов.thesz, дискуссия переместилась в блог archimag-а. Там речь зашла о такой ошибке, как целочисленное переполнение. На что vsevolod заявил, что поскольку в Lisp-е целочисленные переменные имеют возможность автоматически расширяться в зависимости от значения, то в Lisp-е ошибок с переполнением int-ов быть вообще не может. Понятное дело, что я тут же привел контрпример, который вообще не зависит от языка программирования. Но от него отмахнулись. Мол, это частный случай, который ничего не доказывает, а в общем случае никаких проблем нет.

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

Вероятность возникновения ошибки – это совсем не тоже самое, что вероятность повстречать динозавра на улице. К ошибкам, думаю, нужно относиться по другому. Дело не в вероятности ее возникновения – закон подлости (в купе с законом бутерброда и генеральским эффектом) обеспечит ее устойчивый рост вплоть до единицы. Т.е. ошибка рано или поздно проявится. Гораздо важнее время ее возникновения и ее последствия.

В качестве примера последствий хочу рассказать про свою самую памятную ошибку с переполнением целочисленных переменных (точнее, с переходом через 0 к UINT_MAX для unsigned int переменной).

Сделал я тогда специальный компонент в нашем шлюзе коротких сообщений – он обеспечивал нормализацию всплесков трафика от партнеров, чтобы не переполнять выделенные нам каналы. Т.е. если пришлет провайдер сразу 1000 сообщений, то мы плавно распределим эту тысячу в течении нескольких секунд по каналу с пропускной способностью 200 сообщений в секунду. Проработал этот компонент несколько месяцев, даже под суровой нагрузкой. Но однажды ночью меня разбудил экстренный звонок из службы техподдержки. Мол, один из наших каналов заткнулся, очереди сообщений от провайдеров пухнут, а ничего по каналу от нас не идет. Пока я проснулся, пока шарманку раскочегарил, пока разобрался что к чему… В общем, минут 30 канал стоял колом. За что мы потом гневный факс от клиента получили. А дело оказалось в том, что при определенной ситуации с тайм-аутами и отрицательными ответами счетчик активных транзакций переходил через ноль в обратную сторону. Понятное дело, что такого быть не должно было, по замыслам-то. Но случилось.

Вероятность этой ошибки была изначально очень мала, но за несколько месяцев эксплуатации она таки материализовалась. И последствия были весьма неприятными. Поэтому я бы предпочел помощь со стороны компилятора в обнаружении ошибок и unit-тесты, а не одни unit-тесты. И поэтому мне не понравилась точка зрения лисперов, с которыми я спорил. Считают они, что сейчас с инструментами и инфраструктурой для обнаружения ошибок все в порядке – флаг им в руки. Я так не считаю. И спать по ночам хочу спокойно.

Кстати, это практически закономерность: если в системе 24x7 внезапно проявляется какой-то ядреный баг, то происходит это почти всегда почему-то ночью ;)

Напоследок приведу еще одну цитату из archimag-а:

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

К сожалению, я не знаю, что archimag называет “нормальным процессом разработки”. Но если в это понятие входит строгая дисциплина с выделенными ролями архитекторов, разработчиков, тестировщиков, с круглосуточно работающими continuous integration серверами, с централизованными bug tracker-ами и прочими атрибутами промышленной разработки ПО… То лично мне кажется, что очень все это недешево. И если бы средства программирования позволили бы уменьшить объем тестирования и сократили бы время на локализацию и устранение ошибок, то разработка ПО стала бы обходиться дешевле. К счастью, потихоньку в этом направлении и движемся.

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