информационная безопасность
без паники и всерьез
 подробно о проекте
Rambler's Top100Все любят медSpanning Tree Protocol: недокументированное применениеАтака на Internet
BugTraq.Ru
Русский BugTraq
 Анализ криптографических сетевых... 
 Модель надежности двухузлового... 
 Специальные марковские модели надежности... 
 Microsoft обещает радикально усилить... 
 Ядро Linux избавляется от российских... 
 20 лет Ubuntu 
главная обзор RSN блог библиотека закон бред форум dnet о проекте
bugtraq.ru / библиотека / программирование
БИБЛИОТЕКА
вход в библиотеку
книги
безопасность
программирование
криптография
internals
www
телефония
underground
беллетристика
разное
обзор: избранное
конкурс
рейтинг статей
обсуждение




Подписка:
BuqTraq: Обзор
RSN
БСК
Закон есть закон




Клиника плохого кода
Овик Меликян
Опубликовано: dl, 22.08.07 12:55

Исходный код программ, которые создают программисты во всем мире, по качеству можно разделить на четыре категории: хороший код, плохой, ужасный и с трудом компилируемый хаос.

В последнее время мне пришлось иметь дело с исходным кодом одной исключительно, адски плохо спроектированной и хаотически реализованной программы, которую в течение шести лет писали люди, едва ли имеющие отношение к программированию, если забыть об их дипломах в 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 "<i>" + text + "</i>";
}

Ошибка здесь кроется в том, что аргумент text - это сырой текст (по крайней мере, судя по его названию), который оборачивается в тэги и превращается в HTML. Если text содержит, например, угловые скобки, то он нарушает формат, и результат вывода в браузере непредсказуем.

***

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

При этом оставался некоторый класс ошибок, который казался не таким тривиальным. Например, довольно много времени было потрачено на отловку одной неприятной ошибки, связанной с глобальным системным объектом Event, к которому имели доступ несколько процессов. Другая, не менее каверзная ошибка была связана с доступом к глобальной переменной из разных потоков исполнения.

Но если вдуматься, даже такие ошибки являются чисто "шахматным" упущением, с той лишь разницей, что здесь мы имеем дело с объектами с очень сложными побочными эффектами. Чего только стоит такая вещь, как обращение к глобальной переменной int, но из разных потоков исполнения и без запирания: анализ возможных вариантов этого сценария - тема целой статьи, если не диссертации.

Еще один яркий пример объекта со сложными побочными эффектами - это системный вызов write(). Полюбуйтесь - сама невинность:

write(fd, buf, size);

Куда вы посылаете этим вызовом свои данные, и что может с ними произойти? В UNIX, где програмные интерфейсы унифицированны и упрощены до неприличия, эта функция приложима к сетевым гнездам, пайпам, файлам, причем в последнем случае это может быть файл на удаленной машине (NFS), может быть файлом типа FIFO, практически любым устройством под /dev, да и вообще непонятно чем на непонятно каком еще устройстве, которое "поднято" с помощью mount. Сложность состоит в том, что если эта функция возвращает ошибку, то порой бывает нелегко решить что же делать дальше, тем более, что, например, после разрыва соединения с NFS-узлом или зависания нашей же системы, мы даже не узнаем какая часть буфера все таки дошла до места и записалась, а какая - нет. Не забудем, пожалуй, и о другой неприятной ошибке, связанной с write() - переполнением диска, восстанавливаться после которой умеют далеко не все программы.

Все это и есть факторы, которые следует включить в "шахматный перебор" чуть ли не всякий раз, когда вызывается эта функция. Но отчаиваться не стоит, потому что вобщем-то подобных грозных функций-айсбергов в UNIX не так уж и много. В отличие от Windows, программирование в котором похоже на плавание в арктических льдах.

Итак, мы уже примерно представляем, что всякая ошибка в коде является результатом недостаточно глубокого анализа всех возможных последствий взаимодействия объектов кода. А количество ошибок на квадратный сантиметр кода у разных программистов связано, как это ни обидно, с интеллектуальными способностями, хотя и с опытом тоже. При чем здесь интеллект? - спросите вы. А при том, что способность производить анализ ситуации путем перебора вариантов - сознательно или подсознательно, и чаще второе - является одним из фундаментальных свойств психики, но опять же, скорость перебора зависит еще и от опыта и натренированности в конкретном классе задач.

Еше одним, но уже косвенным источником ошибок, очевидно, является плохая и/или раздутая архитектура, которая порождает сценарии, едва поддающиеся "шахматному" анализу. И конечно мы не обойдем вниманием тему читабельности кода.

 

Лечение: локализация и минимизация

Итак, суть "шахматного" метода отлова ошибок в том, что при написании какого-то изолированного куска кода, например одной небольшой функции, следует сконцентрироваться на всех участвующих в этой вечеринке объектах, мысленно перебрать в уме все их свойства, включая всё, что известно об их взаимодействии с другими объектами, а также о побочных эффектах.

Отсюда логически следует и то, что для облегчения своей задачи мы должны минимизировать количество объектов, и по возможности использовать те, которые попроще - касается как переменных, так и функций. Это, очевидно, позволит уменьшить дерево поиска.

И наконец, не менее важный принцип: описания объектов должны быть по возможности локализованы и быть таким образом "перед глазами". Одно следствие из этого принципа - избегать глобальных/статических переменных, а второе следствие - тело функции не должно быть слишком большим.

Существует легенда, согласно которой в стародавние времена в фирме IBM было такое правило: одна функция не должна была занимать больше 24-х строк - именно столько помещалось на экране алфавитно-цифрового дисплея (плюс статус-строка редактора - это уже 25-ая). Словом, вся функция должня была умещаться на экране, и для ее разбора не требовалось бы прокручивать текст вверх и вниз. Мудрое правило!

С появлением графических систем это правило можно слегка релаксировать и позволить, например, 30 строк. Но насколько мне известно, в настоящее время в IBM уже нет таких правил, а и не мудрено: теперь там практически всё пишется на Java, а уместить даже самый простой Java-класс на одном экране - задача не из легких. Да, именно класс, ведь он в языке Java является как бы единицей кода, вместо функций в обычных языках.

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

Как мы учимся играть в шахматы? Вспомните себя, когда вам впервые объяснили правила, и вы пробовали делать первые ходы. Вам приходилось медленно, почти что в слух, рассуждать обо всех возможных исходах каждого рассматриваемого варианта. Возможно уже очень скоро вы научились делать осмысленные ходы, если не считать, что у вас уходило много времени и энергии на разбор.

Если вам полюбились шахматы, и вы стали играть их регулярно, то вы наверняка довольно скоро уже увеличили скорость обдумывания ходов, а заодно и углубили проход по дереву поиска. И наконец, если вы стали мастером, то вы заметили, что временами какие-то ответственные, переломные, сильные ходы всплывают сами по себе, интуитивно. Конечно всякий интуитивный ход надо еще проверить, но все таки вы доверяете своим чувствам, и идя на их поводу... выигрываете. (Если только ваш противник не обладает еще более мощной интуицией, конечно.)

Так происходит во всех областях деятельности человека: какие-то механизмы решения задач отрабатываются нашим сознанием настолько, что скоро "уходят" в подсознание в виде готовых алгоритмов, освобождая от этой работы верхние слои психики. Подсознание обладает более мощными вычислительными ресурсами, и поэтому ответы, возникающие "ниоткуда", появляются довольно быстро. Это есть профессиональная интуиция.

На самом деле многие опытные программисты уже давно обладают той интуицией, которая необходима для отлова ошибок. Метод перебора нужен начинающим, хотя также и тем, кому не нравится собственный рейтинг ошибок (в смысле их количества в пересчете на строку кода). Никогда не поздно попробовать метод перебора сознательно и тем самым проверить, не упускали ли вы до сих пор из виду какие-то важные детали. Взять хотя бы ту же проблему переполнения, или может побочные эффекты функций, которые вы часто используете.

Со временем это станет привычкой, и наконец, как и с шахматами, вы обретете тот уровень мастерства, когда наводки на потенциальные проблемы будут всплывать из "ниоткуда", интуитивно.

Но кроме этой интуиции нужно кое-что другое, чем по моим наблюдениям обладает уже меньшее количество программистов: способность облегчать себе работу упрощая код.

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

Возьмем задачу квадратного уравнения, и допустим, что у нас имеется некий класс QuadraticEquation, написанный в лучших традициях ООП. Там есть возможность присваивать коэффициенты, затем получать корни, и что самое главное, класс этот - переиспользуемый, то есть вы можете изменить, к примеру, только один коэффициент, и снова пересчитать корни. Описание класса я опущу, потому что оно будет очевидным из этого куска кода:

QuadraticEquation qe(10, 20, 2);

qe.solve();

double x1 = 0;
double x2 = 0;
int numRoots = qe.getNumRoots();
if (numRoots > 0)
{
    x1 = qe.getX1();
    if (numRoots == 2)
        x2 = qe.getX2();
}

после чего можно уже переиспользовать объект qe с другими коэффициентами, изменяя их выборочно через методы setA(), setB() и setC().

Но если задуматься, то возникает целая куча вопросов: а пересчитываются ли корни всякий раз, когда я присваиваю коэффициент? А что возвращает getNumRoots(), getX1() и getX2(), если я присвоил новые коэффициенты, но еще не вызвал solve()? Что возвращает, в частности, getX2(), если был только один корень?

Обратись к документации, скажете вы, или загляни в исходник - там наверняка все описано. Документация - это хорошо и очень правильно, но почему вообще должна возникать необходимость заглядывать в документацию для такой простой вещи, как квадратное уравнение? Возможно ли было этого избежать?

Один вариант - написать класс, который будет целиком idiot proof, то есть можно будет вызывать любые методы в любой последовательности, и этот класс, делая минимальные движения, будет возвращать какие-то значения, по большей части релевантные.

Я попробовал реализовать два варианта: один - "в лоб", очень простой, а второй - стойкий, и в котором все предусмотрено. Во-первых, разница между ними оказалась немаленькой, примерно на 60-70% в смысле строк кода. И во-вторых, хоть и для квадратного уравнения написать "стойкий" класс было сравнительно несложно, в реальности писать такие idiot proof классы - отдельное искусство, требующее немалых интеллектуальных затрат. Если бы только знать ради чего это делается! А ради объектной ориентированности - без этого в наше неспокойное время уже никак.

Иметь дело с классами вообще - это как если бы в игре в шахматы вам сказали, что в партии участвуют некие фантомные фигуры, которые находятся за пределами доски, но тем не менее они участвуют в игре. Для того чтобы включить их в анализ, вам пришлось бы все время заглядывть в блокнот, где записаны их фантомные позиции. Неприятная игра, надо сказать.

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

double x1 = 0;
double x2 = 0;
int num_roots = qe_solve(10, 20, 2, x1, x2);

Всего 3 строчки, и тот же результат! Но дело не только в строчках, хотя это уже не мало. Вместо интерфейса с дюжиной методов мы имеем всего одну функцию, предназначение которой почти на 100% прозрачно, разве что надо будет помнить что она возвращает количество корней - 0, 1 или 2. Одна функция, которая что-то берет и что-то возвращает в одной точке, которая у нас перед глазами, рядом с другими объектами.

Реализация этой функции, кстати, тоже минималистична: одним махом мы избавились от всех элементов, скажем так, метапрограммирования: конструкторов и всевозможных методов доступа к внутренним полям класса. Одна простая, компактная функция, которая занимается только лишь решением задачи, и ничего лишнего.

И наконец, корни x1 и x2 существуют только в одной копии, когда как в первом варианте они были еще и внутри объекта qe.

Разве это не красиво? И имеет ли первый способ вообще какие-либо преимущества перед вторым?

Конечно, случай простой, и наверняка вы сможете привести кучу примеров, где без классов не обойтись, и я с вами вынужденно соглашусь. Речь о том, что в реальности часто можно упростить себе задачу, пусть даже в рамках ООП, если забыть о стереотипах и подойти к вопросу с практической точки зрения. Можно ли было записать это проще, если использовать более простые объекты и если их локализовать? Ответ на этот вопрос довольно часто положительный - чаще чем, возможно, вы думаете.

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

 

Лечение: архитектура

Кто-то из математиков сказал: если имеется нерешаемая задача, сначала ее надо разбить на подзадачи, решение которых известно. Это есть сущность арихитектуры: для того, чтобы система оставалась управляемой и maintainable для разработчика, ее следует разбить на части и определить интерфейсы и протоколы взаимодействия этих частей.

В теории часто говорят о модульности, однако по какой-то непонятной причине наиболее модульные языки, как Паскаль и Ада, постепенно вышли из моды. А ведь они прививали дисциплину стройной древовидной модульности и дисциплину мышления в терминах интерфейс-реализация. Оглядываясь назад, могу сказать, что практика программирования на Turbo Pascal и Delphi заменила мне чтение всевозможных умных книжек по архитекруре. (Ах, да, пресловутые begin...end вместо фигурных скобок - вот что потопило эти замечательные языки.)

Но былого не вернешь, и сегодня большинство пишут либо на Си++, где нет понятия модуля, либо на Java/C#, где есть некое подобие модулей, но нет деления на интерфейс и реализацию, хотя бы даже как в Си++. И куда катится этот мир? Полагаю, катится он в сторону какого-то хорошего нового языка, с хорошей модульностью, с лямбда-исчислением и ООП одновременно, и прочими крутыми штуками, - языка, которого пока еще нет.

То, что большинство разработчиков и архитекторов (никогда не понимал смысл этой специальности - человек, который не кодирует, но указывает как это надо делать) чаще всего упускают из виду, это следующая важная мысль: функции, особенно часто используемые, становятся частью языка программирования, и следовательно к их дизайну следует подходить примерно с такой же серьезностью, как к дизайну языка. Когда мы излагаем мысль на человеческом языке, мы по сути мыслим понятиями этого языка; аналогично, писать программу на алгоритмическом языке с использованием какого-то набора функций означает излагать мысль на этом формальном языке. Когда мы предлагаем другим разработчикам нашу библиотеку, мы навязываем им некий набор понятий и образ мышления.

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

Итак, главная дилемма архитектуры - это должны ли интерфейсы каждого модуля быть всеобъемлющими, или наоборот, они должны экономить понятия и давать минимальный набор гибких средств? Если спросить пользователей библиотек, они проголосуют за второе, но - парадокс! - перелезая в шкуру разработчика библиотеки, нам начинает казаться, что "правильнее" первое. Есть о чем подумать, не правда ли?

Я вам собираюсь показать, как несколькими легкими движениями можно сильно сократить или даже и вовсе избавиться от довольно громоздкого программного интерфейса.

Возьмем Windows Registry. Его предназначение - хранить дерево каких-то числовых и строковых значений, предположительно для хранения конфигурации приложений и самой системы. Эта функциональность описана в виде интерфейса с 41 функциями (!), примерно от 3 до 7 аргументов каждая, плюс какие-то структуры, и как водится, куча констант.

Описание каждой из 41 функций - довольно солидный текст (пример). Оцените примерно время, которое у вас уйдет на изучение этого тиранозавра, и прибавьте время, пока вы поймете какие 5-6 функций вам понадобятся из всей кучи. При этом весь API кажется логичным; каждая функция имеет свое предназначение в жизни, и есть ощущение того, что она когда-либо может понадобиться.

Но постойте, 41 функция с 3-7 параметрами - это для работы с деревом простых значений? Вы шутите?

Какие на самом деле функции нужны для работы с деревом значений? Начнем с того, что я никогда не понимал зачем в Windows Registry ввели отдельные понятия Key и Value. Вероятно, по образу и подобию файл/фолдер. Но для дерева, если подумать, достаточно и одного понятия - пусть это будет Key, который может иметь под-ключи и может иметь значение. Чем не дерево?

И тогда интерфейс работы с Registry сведется к следующим четырем глаголам: create, write, read, delete. Каждая из функций принимает в качестве параметра путь к ключу. Создать, записать, прочесть, стереть. Наверное можно будет добавить еще пару-тройку функций, регулирующих права доступа, что в Windows - вещь вообще не очень понятная, но это уже не наша проблема. Что-то еще?

Но у меня есть еще такая домашняя заготовка, которой мы нанесем Windows Registry удар ниже пояса. Мы решим задачу хранения конфигурационных данных в юниксоидном стиле.

Представьте себе, что вы установили в своей UNIX-системе некий продукт под названием UNIX Registry. Вы сразу открываете документацию, чтоб понять как с этим делом работать, и ожидая сотни страниц умного текста, обнаруживаете следующий параграф:

Здравствуйте, я ваш путеводитель по UNIX Registry. UR - это специальная файловая система, предназначенная для эффективной работы с очень маленькими файлами. Приложения могут использовать UR для хранения своих конфигурационных данных в виде дерева значений. По соглашению, эта файловая система маунтируется под /var/reg. Каждое приложение создает свое поддерево (напр. /var/reg/xcalendar), в котором рекомендуется также создать отдельные поддеревья для каждого пользователя. Права доступа регулируются как обычно; естественно, в директориях UR вы можете использовать все стандартные файловые утилиты, например find. Для удобства программирования, флаг "x" на файле в UR означает, что файл может хранить только числовое значение. Приятного отдыха.

Вот и всё. Всё!

Позволю себе дать еще три кратких совета по архитектуре и протоколам: во-первых, используйте технологии Интернета насколько это возможно, например для обмена данными - протокол HTTP. Это дает такую солидную кучу преимуществ, что для их описания понадобилась бы отдельная глава. Попробуйте сами, выберите правильный инструментарий, и поймете о чем речь. В конце концов, не зря технологии Интернета уже победили все остальные технологии.

Во-вторых, избегайте XML где возможно решить задачу более простыми текстовыми форматами в юниксоидном стиле. XML is a pain in the ass. Реально, он едва ли что-то упрощает в нашей жизни. Впрочем, может вам как раз надо усложнить?

В-третьих, для решения задачи выберите такой язык и такие библиотеки, чтобы основной код состоял исключительно из логики решения этой задачи, и ничего лишнего. Другими словами, никакого метапрограммирования, когда, например, приходится буквально нянчиться с COM/DCOM или же описывать крутые Си++ шаблоны, о которые сам черт ногу сломает. Нужно ли оно вам?

Словом, выбирайте такие интерфейсы и протоколы, чтобы пришлось меньше писать кода и зависеть от меньшего количества внешних компонент. Какая простая и очевидная мысль, и как часто мы выбираем в точности противоположную стратегию!

И наконец:

Large Systems Suck. This rule is 100% transitive. If you build one, you suck. -- Steve Yegge, Google Inc.

 

Лечение: читабельность

Все вокруг, куда ни глянь, говорят о читабельности кода. По интернету разбросано великое множество рекомендаций (часто противоречащих друг другу), и при этом большая часть кода, создаваемого в мире, уже при беглом взгляде не оставляет впечатление читабельного. В чем тут проблема?

Во-первых, принято считать, что существуют две крайности в оформлении исходного кода: он может быть слишком многословным, или же слишком лаконичным и запутанным. (Вообще-то приверженцы языков Java и C# не считают многословность грехом, но оставим это на их совести.) Здравый смысл подсказывает, что хороший код должен быть где-то посередине: он должен быть лаконичным и в то же время не быть запутанным. Человек должен тратить как можно меньше времени на чтение и понимание кода, а для этого код должен физически занимать меньше места, содержать меньше слов, и вместе с тем его предназначение должно быть достаточно прозрачным. Вроде логично.

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

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

Но что это означает на практике?

Оказывается не все отдают себе отчет в том, насколько лишние слова и всевозможные кавычки и закорючки затрудняют чтение. Однажды на каком-то форуме обсуждался JavaScript, и я сказал, что к элементам формы можно всегда и во всех вменяемых браузерах обращаться как, например, form.checkbox. На это кто-то мне ответил, что он всегда пишет form.getElementById("checkbox"), при том, что нет никаких причин так поступать. Для того чтобы понять абсурдность такого стиля, напишите пару строк кода, в котором такое обращение будет встречаться, скажем, три раза, и сравните оба способа.

В мире Си++ есть несколько практик кодирования, которые иначе как "вирусными" не назовешь: никто не понимает их смысл, но все их придерживаются. К таким практикам относится прежде всего т.н. "венгерская нотация" - ужасное изобретение, рядом с которым трудно поставить что-либо другое, по ущербу, нанесенному читабельности кода на Си++. Кстати, создатель ВН уже давно работает в Microsoft, что для меня почему-то не является таким уж удивительным фактом.

Ни одно из "преимуществ", приводимых в защиту этого стиля, не звучит настолько убедительно, чтобы перевесить то месиво, в которое этот стиль превращает исходный код:

m_bBMP = pHeader->guidFormatID == WiaImgFmt_MEMORYBMP
    || pHeader->guidFormatID == WiaImgFmt_BMP;
m_nHeaderSize = pHeader->guidFormatID == WiaImgFmt_MEMORYBMP
    ? sizeof(BITMAPFILEHEADER) : 0;
if (pHeader != NULL && pHeader->lBufferSize != 0)
{
    hResult = ReAllocBuffer(m_nHeaderSize + pHeader->lBufferSize);
    if (FAILED(hResult))
    {
        return hResult;
    }
}

Чтобы понять насколько это плохо, и насколько это против наших языковых инстинктов, попробуйте прочесть этот текст:

прилВенгерская сущНотация, предлПри числВсем сущУважении предлК собсущВенгрии союзИ местЕе сущЧленстве предлВ прилЕвропейском сущСоюзе союзИ прилДругих прилКрутых сущОрганизациях, глагЯвляется прилПервым сущВрагом сущЧитабельности.

Поэкспериментируйте сами: уберите префиксы из листинга любой "венгерской" программы, и увидите насколько станет легче жить. Все проблемы, которые вы пытались разрешить с помощью ВН, на самом деле решаются иначе, а именно с помощью локализации, о которой мы уже говорили.

Прочие практики и стили программирования тоже можно тестировать на "человечность". Например, есть еще один стилевой вирус, который остался по наследству от не очень умных компиляторов:

if (0 == x)
    // ...

Как мы произносим подобное вслух? Нормальные люди обычно говорят: "если икс равен нулю", но никак не "если ноль равен иксу". Так зачем же писать не по-человечески, тем более, если современные компиляторы и так не дадут вам ошибиться и написать "=" вместо "==" (а это и есть причина, по которой возник этот стиль). Уже не говоря о том, что зрелый Си++ программист просто не должен делать такой ошибки.

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

И не забудьте о том, что комментарии дополняют код, а не переформулируют его: поскольку мы приближаем программы к человеческому языку, то и необходимость в комментариях вроде "// сверить икс с нулем" уже отпадает (хотя необходимости конкретно в таком комментарии особо никогда и не было).

 

Лечение: горячие ванны

Почитайте The Art of UNIX Programming, поизучайте вообще UNIX, если вы этого еще не делали, и погрузитесь в мир, в котором программирование является утонченным, интеллектуальным ремеслом, или даже искусством. Уроки, которые вы извлечете оттуда, помогут писать более вменяемый код даже под Windows, если у вас после этого еще будет охота возвращаться к этому стилю и программным интерфейсам, созданным скорее для роботов, чем для людей.

 

***

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


Ваше мнение?

Оригинал: http://www.melikyan.com/dalshe/articles/badcode.html

обсудить  |  все отзывы (54)

[57892; 132; 8.03]






Rambler's Top100
Рейтинг@Mail.ru





  Copyright © 2001-2024 Dmitry Leonov   Page build time: 0 s   Design: Vadim Derkach