Продолжение темы, начатой несколько дней назад. Расскажу о том, почему спор с лисперами в лице тов.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-ами и прочими атрибутами промышленной разработки ПО… То лично мне кажется, что очень все это недешево. И если бы средства программирования позволили бы уменьшить объем тестирования и сократили бы время на локализацию и устранение ошибок, то разработка ПО стала бы обходиться дешевле. К счастью, потихоньку в этом направлении и движемся.