Клиника плохого кода
Опубликовано 25.09.2007 - В рубриках: Кодинг
Исходный код программ, которые создают программисты во всем мире, по качеству можно разделить на четыре категории: хороший код, плохой, ужасный и с трудом компилируемый хаос.
В последнее время мне пришлось иметь дело с исходным кодом одной исключительно, адски плохо спроектированной и хаотически реализованной программы, которую в течение шести лет писали люди, едва ли имеющие отношение к программированию, если забыть об их дипломах в Computer Science из разных авторитетных университетов мира. Все что должна была делать эта программка - это копировать файлы с одной Windows-машины на другую. Эта задача была решена в 80,000 строк кода на Си++ (функциональная часть) и в 55,000 строк кода на VB (GUI часть). Компиляция порождала примерно 10 файлов: 6 EXE и 4 DLL. На одном конце устанавливались 3 системных сервиса и две COM компоненты, а на другом - 2 сервиса, одна COM компонента и собственно графический интерфейс для управления этим монстром.
Когда я показал одному из авторов этого произведения, что на UNIX задача решается примерно в 10 строк кода на шелл-скрипте, он был изумлен, и признался, что вероятно ему еще много чему предстоит поучиться. Но если бы проблема была только в плохой архитектуре.
Очередная версия x.x.3, которая была исправлением версии x.x.2, была передана в отдел QA, и уже через пару недель QA вернул мне ее примерно с 40 найденными ошибками, которые появились в x.x.3. (А авторы к тому времени уже ушли из компании.) Я параллельно сам нашел в программе еще дюжину ошибок, но - грешен! - не сообщил о них, поняв, что ситуация катастрофическая, и мне надо выкручиваться. То есть поскорее сдать версию, и убедить начальство, что код надо переписать. Уже не говорю о том, что изучая его, я понял, что в принципе каждая пятая строка содержит потенциальную ошибку, которая может всплыть при определенных обстоятельствах.
“Никогда еще Штирлиц не был так близок к провалу.”
И ладно, если бы это писал один выпускник университета. В исходниках я нашел примерно 7 разных фамилий, и даты, самая ранняя из которых - июль 2001-го года. Это, напомню, была программа для копирования файлов с одной Windows-машины на другую.
К счастью, вопрос переписывания вскоре решился положительно, но поскольку я впервые в жизни столкнулся с подобным чудовищем, это не на шутку взбудоражило мою бескомпромиссную душу физика (нескромно, да?) и заставило задуматься о философии, психологии и собственно клинике этого явления - плохого кода. На руках я имел хороший материал для изучения.
Ошибки: этиология и патогенез
В общем случае, программа - это функция, которая берет аргументы и возвращает некий результат; сама программа состоит из функций, каждая из которых решает некоторую часть задачи. Ошибка в программе или функции - это когда при определенных входных параметрах результат не совпадает с ожидаемым.
В реальном мире существуют программы, которые на первый взгляд далеки от такого определения, например интерактивные или сетевые: и те, и другие содержат код, который взаимодействует с внешними устройствами, в частности интерактивные программы - с клавиатурой, мышью, итд, а сетевые - принимают и посылают данные по сети. Хотя это свойство привносит в вычисления некоторую нелинейность, формально мы можем отнести любое взаимодействие с внешним миром к “функциям с побочным эффектом”. Если программа, например, вызывает функцию чтения сетевого гнезда, то значит в этой точке можно ожидать такие неприятные вещи, как “зависание” на неопределенное время или исключительная ситуация с разъединением кабеля, которые и делают нашу стройную дискретную теорию конечных автоматов такой нестройной и уже не очень-то предсказуемой. Но, опять же, мы упростим наше исследование тем, что назовем все это “побочными эффектами”.
Так в чем причины возникновения ошибок? Почему нечто, описанное словами или же в математической нотации, может быть реализовано неверно? И самое интересное - почему это случается так часто?
Попробуем написать очень простую программу (а на самом деле функцию - какая разница?), которая вычисляет среднее значение двух аргументов:
int avg(int a, int b) { return (a + b) / 2; }
Правильно?
Вы конечно узнали язык Си, и скорее всего не заметили в чем тут подвох. Не отчаивайтесь: согласно одному исследованию, подобные вещи не замечают 95% программистов. Дело в том, что при сложении (a + b) может произойти переполнение, когда как результат - ведь он же среднее двух аргументов - никогда не должен переполняться. Значит надо переписать функцию так, чтобы она не была подвержена переполнениям:
int avg(int a, int b) { return a + (b - a) / 2; }
Правильно?
Уже лучше, но опять не то. На этот раз мы забыли, что аргументы могут быть отрицательными. Если, например, один из аргументов будет негативным, то выражение (b - a) может дать переполнение. Проверим так же случай, когда оба негативные - нет, вроде все нормально. Следовательно, окончательная версия нашей функции будет выглядеть так:
int avg(int a, int b)
{
if (sign(a) != sign(b))
return (a + b) / 2;
else
return a + (b - a) / 2;
}
Так можно ли было избежать этих ошибок с самого начала?
Давайте мысленно прокрутим весь процесс. Сначала мы написали код, который мы буквально транслировали с человеческого языка: “вычислить среднее двух аргументов”, и записали его в одну строку. Затем мы начали вчитываться в код. Все что у нас есть - это две переменные типа int и две арифметические операции - сложение и деление. Восстановив в нашей памяти все что мы знаем о типе int - как-то: что он имеет определенный диапазон, а затем все, что мы знаем об арифметических операциях, перебрав в уме некоторые граничные случаи, мы пришли к выводу, что иногда результат вычисления будет отличаться от ожидаемого.
Поняв далее, что знак аргументов в данном случае является болезненной точкой, мы начали перебирать все возможнные сочетания, и в результате получили код, который работает при любых значениях a и b.
Хм… перебор вариантов? Вот именно, как в шахматах!
Играя в шахматы, мы полностью отключаемся от внешнего мира и концентрируемся на правилах игры и также на фигурах, которые имеются на доске - это все, что нам нужно в данный момент. Учитывая возможные ходы для каждый из фигур, мы начинаем перебирать варианты: “если я пойду так, он может пойти так, а тогда я пойду так” и так далее. Временами мы возвращаемся на шаг назад: “а если он пойдет так, то я пойду так”. Это называется дерево поиска (search tree), или перебор вариантов. Это единственный метод игры в шахматы, который применяется как человеком, так и шахматными компьютерами.
Создавая компьютерный код, мы порождаем некую игру с определенными фигурами и правилами. Не то чтобы сама функция является игрой - нет, скорее процесс написания. Код состоит из таких базовых понятий, как переменная, константа, функция (включая все встроенные арифметические и прочие функции) и также операторы циклов и ветвлений. Каждый из этих объектов - как шахматная фигура, которая обладает определенными свойствами, и для того, чтобы убедиться, правильно ли написан код, мы мысленно перебираем все возможные варанты взаимодействия этих “фигур”.
Например: будет ли выражение (a + b), как в нашем примере выше, всегда давать ожидаемый результат? Очевидно, нам не придется перебирать все возможные варианты значений для обоих аргументов, а достаточно рассмотреть лишь граничные случаи, то есть: отрицательные значения, положительные, близкие к границе диапазона int, и все возможные их сочетания для двух аргументов. В случае умножения или деления возможно будет полезно рассмотреть и нулевые значения.
Аналогично, граничными случаями для текста чаще всего можно считать пустую строку, для указателей - NULL, для списков - пустой список, для индекса массива - вхождение в диапазон, итд. Всякий раз, имея дело с указателем, например, следует задавать себе вопрос: а может ли он быть пустым, то есть NULL? Если да, то что делать в таком случае? Вспомните, как часто в вашей практике перед вами всплывало сообщение “Null pointer operation”. Если вы программируете на Си/Си++, то наверняка видели это не раз. А на самом деле много раз.
UPD В динамических бестиповых языках добавляется еще и морока с типами: переменная может иметь тип, который отличен от ожидаемого, а автоматическая конвертация не всегда интуитивна, например: что получается при концвертации строки или массива/списка в булево значение? Булево - в строку?
Есть еще класс чуть более тонких ошибок, связанных с тем, что если даже компилятор позволяет произвести какую-то операцию над операндами, это еще ничего не значит: операнды могут иметь разную семантику. Например:
string italicize(string text)
{
return “” + text + ““;
}
Ошибка здесь кроется в том, что аргумент text - это сырой текст (по крайней мере, судя по его названию), который оборачивается в тэги и превращается в HTML. Если text содержит, например, угловые скобки, то он нарушает формат, и результат вывода в браузере непредсказуем.
***
Анализируя имеющуюся у меня на руках статистику по ошибкам в той гениальной программе, копирующей файлы, я сначала подумал, что некая существенная их часть является результатом упущения каких-то граничных случаев. Например, не учитывалось, что некая функция может вернуть NULL-указатель, или что значение, которое добавлялось в цикле к другому, могло быть нулем, и таким образом программа могла “застрять” в цикле навсегда.
При этом оставался некоторый класс ошибок, который казался не таким тривиальным. Например, довольно много времени было потрачено на отловку одной неприятной ошибки, связанной с глобальным системным объектом Event, к которому имели доступ несколько процессов. Другая, не менее каверзная ошибка была связана с доступом к глобальной переменной из разных потоков исполнения.
Но если вдуматься, даже такие ошибки являются чисто “шахматным” упущением, с той лишь разницей, что здесь мы имеем дело с объектами с очень сложными побочными эффектами. Чего только стоит такая вещь, как обращение к глобальной переменной int, но из разных потоков исполнения и без запирания: анализ возможных вариантов этого сценария - тема целой статьи, если не диссертации.
Еще один яркий пример объекта со сложными побочными эффектами - это системный вызов write(). Полюбуйтесь - сама невинность:
write(fd, buf, size);
Куда вы посылаете этим вызовом свои данные, и что может с ними произойти? В UNIX, где програмные интерфейсы унифицированны и упрощены до неприличия, эта функция приложима к сетевым гнездам, пайпам, файлам, причем в последнем случае это может быть файл на удаленной машине (NFS), может быть файлом типа FIFO, практически любым устройством под /dev, да и вообще непонятно чем на непонятно каком еще устройстве, которое “поднято” с помощью mount. Сложность состоит в том, что если эта функция возвращает ошибку, то порой бывает нелегко решить что же делать дальше, тем более, что, например, после разрыва соединения с NFS-узлом или зависания нашей же системы, мы даже не узнаем какая часть буфера все таки дошла до места и записалась, а какая - нет. Не забудем, пожалуй, и о другой неприятной ошибке, связанной с write() - переполнением диска, восстанавливаться после которой умеют далеко не все программы.
Все это и есть факторы, которые следует включить в “шахматный перебор” чуть ли не всякий раз, когда вызывается эта функция. Но отчаиваться не стоит, потому что вобщем-то подобных грозных функций-айсбергов в UNIX не так уж и много. В отличие от Windows, программирование в котором похоже на плавание в арктических льдах.
Итак, мы уже примерно представляем, что всякая ошибка в коде является результатом недостаточно глубокого анализа всех возможных последствий взаимодействия объектов кода. А количество ошибок на квадратный сантиметр кода у разных программистов связано, как это ни обидно, с интеллектуальными способностями, хотя и с опытом тоже. При чем здесь интеллект? - спросите вы. А при том, что способность производить анализ ситуации путем перебора вариантов - сознательно или подсознательно, и чаще второе - является одним из фундаментальных свойств психики, но опять же, скорость перебора зависит еще и от опыта и натренированности в конкретном классе задач.
Еше одним, но уже косвенным источником ошибок, очевидно, является плохая и/или раздутая архитектура, которая порождает сценарии, едва поддающиеся “шахматному” анализу. И конечно мы не обойдем вниманием тему читабельности кода.
Овик Меликян
Комментарии
Оставьте отзыв


