Последнее изменение: 1 сентября 2007г.

Качественный код – слагаемые

Для начала я вообще хочу определить, о чем пойдет речь. Ибо качество кода многие неразрывно отождествляют с качеством приложения – устойчивостью, скоростью, безопасностью и т.п. Я хочу поговорить несколько о другом – о качестве именно программного кода. Это не одно и то же. К сожалению, есть много примеров быстрых, устойчивых приложений, качество кода в которых оставляет желать лучшего. И наоборот – код может быть очень качественным, а приложение никуда не годится. И если качество приложения в основном зависит от продуманности его архитектуры, то качество кода зависит непосредственно от разработчиков.

Итак, что есть качественный код? С позиции менеджера по качеству кода я могу дать следующее определение:

Качественным является код, максимально приспособленный к поддержке.

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

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

  1. ... он следует принятым соглашениям
  2. ... присутствует обработка исключительных ситуаций
  3. ... он документирован
  4. ... он легко читается

Остановимся на каждом из правил подробнее.

Соглашения кода (code conventions)

Это правило основное. Под соглашениями кода я подразумеваю, прежде всего, соглашения по его форматированию. Где ставится открывающая фигурная скобка – на той же строке, где if, или на следующей? Как расположены параметры метода – в ряд, или по одному на строке? Какой отступ для следующего уровня вложенности кода – 2, 4, 8 символов? Ну и т.д.

Мелочи? Возможно. А вот теперь представьте ситуацию, когда в условиях совместной разработки к одному и тому же коду приложились 10 программистов! Причем 10 – это очень малое число, я встречал классы, в числе авторов которых фигурировало более 50 (пятидесяти!) разработчиков. И у КАЖДОГО – свой стиль. Один использует отступы в 3 символа, двое – в 6, один – в 8. Остальные – в 4. Трое ставят открывающие скобки на следующей строке. Как вы думаете – легко ли будет разбираться в таком коде? На мой взгляд – просто НЕВОЗМОЖНО. Будь этот код хоть трижды грамотен, он настолько ужасен по своему виду, что глаз будет спотыкаться на каждом шагу, при каждой смене стиля, а мозг в таких условиях будет воспринимать информацию с ОЧЕНЬ большим трудом, если вообще не откажется это делать. Классический вариант поговорки – "за деревьями леса не видно"

Выход один – стиль надо унифицировать. Это тоже не так просто, как может показаться. В условиях, когда человек пишет на Java (или на чем угодно другом, не суть важно) лет пять, все синтаксические конструкции уже сидят в пальцах. Они натренированы до жесткого автоматизма. Лично у меня фигурные скобки всегда идут парой, причем если я буду очень себя контролировать, то я могу остановиться в тот момент, когда я поставил скобки, подвинул курсор назад, между ними, но еще не успел нажать 'Enter'. Смещение курсора назад у меня не получается контролировать без серьезного замедления скорости набора. В любом случае, такая ломка стереотипов отвлекает часть мозга, что в программировании ни разу не полезно.

Потому в такой ситуации разумно воспользоваться средством, которое есть, как мне кажется, в любой мало-мальски приличной среде разработки – автоформатированием текста. Несложное задание стандартов – и можно отформатировать как текущий исходник, так и весь проект.

Естественно, с нуля изобретать велосипед не стоит. Надо от чего-то отталкиваться. Лучше всего отталкиваться от соглашений, разработанных Sun Microsystems. Эти соглашения можно найти тут – Sun code conventions for Java (открыть эту ссылку в отдельном окне) Разумеется, соглашения стиля кода не ограничиваются форматированием. Это и соглашения о наименованиях переменных, методов, классов, и много чего еще. Эти соглашения можно выработать самим, на основе реалий данной конкретной разработки в условиях данной конкретной компании. Важно, чтобы они были выработаны, и чтобы им следовали.

Следующее правило, приближающее код к качественному –

Обработка исключений

Исключения должны обрабатываться. Это непреложный факт. Хвала создателю языка – компилятор просто не позволит оставить исключение необработанным. Однако, к сожалению, он не может отследить качества обработки, ему важен сам факт.

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

try{
    // тут код, генерирующий исключения, разного типа
}catch(Exception ex){
}

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

catch(Exception ex){
    System.out.println("Exception: "+ex.getMessage());
    ex.printStackTrace();
}

что сути не меняет – исключение не обработано.

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

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

Рассмотрим следующий фрагмент кода. Чтение строки из базы и сохранение ее в файле:

PreparedStatement stmt = ...;
try{
    // throws SQLException
    ResultSet resultSet = stmt.executeQuery();
    // throws SQLException
    if (resultSet.first()){
        // throws UnsupportedEncodingException
        String str = new String(resultSet.getBytes(DATA_IDX),"windows-1251");
        // throws IOException
        BufferedWriter bw = new BufferedWriter(new FileWriter("./out.txt"));
        // throws IOException
        bw.write(str);
        // throws IOException
        bw.flush();
        // throws IOException
        bw.close();
    }
}catch(Exception ex){

}

Что тут может произойти? Может произойти сбой при обращении к базе данных. Тогда будет выброшено исключение java.sql.SQLException. Может не поддерживаться кодировка windows-1251, что вызовет java.io.UnsupportedEncodingException. Может произойти сбой при записи на диск. В этом случае будет перехвачено исключение java.io.IOException.

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

Сравним этот пример со следующим:

PreparedStatement stmt = ...;
ResultSet resultSet = null;
try{
    resultSet = stmt.executeQuery();
}catch(SQLException ex){
    // Oшибка при чтении из базы. Дальнейшее выполнение метода невозможно.
    // Обрабатываем эту ситуацию – бросаем исключение,  выдаем окно с
    // сообщением, и т.п. Главное – управление дальше не идет
}
try{
    if (resultSet.first()){
        String str = null;
        byte[] data = resultSet.getBytes(DATA_IDX);
        try{
            str = new String(data,"windows-1251");
        }catch(UnsupportedEncodingException ex){
            // кодировка windows-1251 не поддерживается.
            // Хорошо, будем использовать стандартную iso-8859-1,
            // уведомив об этом пользователя (либо написав сообщение в лог и т.п.)
            try{
                str = new String(data,"iso-8859-1");
            }catch(UnsupportedEncodingException e){
                // см. комментарии ниже фрагмента кода
            }
        }
        BufferedWriter bw = new BufferedWriter(new FileWriter("./out.txt"));
        bw.write(str);
        bw.flush();
        bw.close();
    }
}catch(SQLException ex){
    // Исключение при вызове resultSet.first(). Дальнейшее
    // выполнение метода невозможно. Обрабатываем эту ситуацию – бросаем
    // исключение, выдаем окно с сообщением, и т.п. Управление дальше не идет.
}catch(IOException ex){
    // Исключение при записи в файл. Обрабатываем ЭТУ ситуацию. Можем
    // попытаться еще раз, и т.п. В случае неудачи – управление дальше не идет.
}

Замечание по поводу обработки второго исключения java.io.UnsupportedEncodingException. Теоретически мы имеем право никак не обрабатывать это исключение. По одной простой причине: любая реализация Java-платформы обязана поддерживать эту кодировку. Об этом сказано в спецификации класса java.nio.charset.Charset. Следовательно, если тут возникнет исключение, это будет означать, что у разработчика этой реализации Java серьезные проблемы. С другой стороны – возможно, не лучшим решением будет дать приложению продолжить работу, даже если проблемы у разработчика платформы, а не у вас. Так что в таких ситуациях каждому придется самому решать, что делать. Можно выбросить какое-нибудь из исключений RuntimeException, можно уведомить пользователя и спросить, что делать дальше... В любом случае, в лог написать сообщение – стоит.

P.S. Спасибо коллеге за комментарий!

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

Небольшое замечание. java.io.UnsupportedEncodingException является подклассом java.io.IOException. В случае, когда во фрагменте кода могут быть выброшены оба эти исключения, обрабатывать необходимо сначала java.io.UnsupportedEncodingException, а уж потом – java.io.IOException. Если попытаться сделать наоборот, то конструкция catch(IOException ex) будет обрабатывать ОБА исключения, а блок catch(UnsupportedEncodingException ex) просто не будет доступен. Впрочем, компилятор об этом любезно предупредит. Главное – поменять блоки местами, а не удалить недоступный.

Итак, мы плавно переходим к следующему слагаемому качественного кода, а именно,

Документирование

Да, да, да, да и еще раз – ДА! ДО-КУ-МЕН-ТИ-РО-ВА-НИ-Е! Я слышу стоны вокруг. Я знаю, насколько нелюбимым занятием является документирование. Иногда даже чересчур. Об одном фатальном случае я расскажу чуть дальше.

Между тем, не так страшен черт, как его малютка. Документировать код достаточно просто, если не запускать это. Лучше всего делать это параллельно с написанием кода. Закончили метод – пишем, что он делает. Каких параметров ждет. Какие исключения могут возникать. По горячим следам описание займет минут пять. А пользы может принести очень много.

Я понимаю, что часто назначение метода очевидно. Часто, но не всегда. Если метод возвращает значение поля объекта – тут документировать особо нечего. Хотя, можно написать, что именно он возвращает, иногда это бывает полезно, особенно, когда объект использует кто-то, незнакомый с его внутренней структурой. Но даже если метод размером всего строк в пять-шесть – уже стОит описать его функции. Есть такое понятие – замыливание взгляда. Когда смотришь на код, и не видишь очевидных вещей. Тогда документация может оказать очень большую помощь. Можно потратить полчаса на копание в коде, а можно пару минут на чтение документации метода. В которой описано, как он себя ведет и почему.

А при реализации сложных алгоритмов (например, топологической сортировки графов) код получается весьма и весьма неочевидным. И не потому, что разработчик такой – алгоритм нетривиальный. И если нет под рукой книги, из которой этот алгоритм взят, то с недокументированным кодом разобраться бывает весьма непросто.

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

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

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

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

Обещанный рассказ о фатальном случае нелюбви к написанию документации. Некий программист из числа моих знакомых задумал ограничить длину ввода в текстовое поле. Делается это в достаточной степени тривиально – реализуется интерфейс javax.swing.text.Document или, что намного проще, наследуется класс от javax.swing.text.PlainDocument и переопределяется метод insertString, в котором и проверяются вводимые данные и предпринимаются определенные действия.

Все бы ничего, но в проекте уже есть девять реализаций javax.swing.text.Document, причем ввести ограничения надо в каждой из них. Казалось бы, что проще – добавить один общий базовый класс в иерархию и переопределить у него insertString. Именно это я и предложил сделать. Почему-то это решение не встретило энтузиазма. Тогда я очень ядовито заметил, что есть и другой способ – ввести ограничение в каждом из девяти классов, иными словами, продублировать код один ДЕВЯТЬ раз. Представьте себе, какой шок я испытал, когда он обрадованно заявил – "О, я так и сделаю!" Когда я пришел в себя, я задал один единственный вопрос: "Зачем?" Ответ меня поверг в еще больший шок: "Если я сделаю новый класс, то мне придется писать к нему документацию. А так – классы уже есть, и мне ничего не придется писать." Финиш.

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

Вот мы и приблизились к четвертому слагаемому качества кода –

Легкая читаемость кода

Дать определение легко читаемого кода, скорее всего, просто невозможно. Мы можем говорить лишь о том, что облегчает или усложняет чтение. Частично этот аспект перекликается с первым из слагаемых – использованием соглашений. В самом деле, поддержание одинакового форматирования, принципов именования и т.п. сильно облегчает восприятие кода. Здеcь же я хотел бы коснуться двух моментов, один из которых упомянут в Sun code conventions, но как-то вскользь, а другой не упомянут вовсе.

Итак, первое, на чем бы я хотел заострить внимание – использование констант. На мой взгляд, это совершенно необходимо. Если что-то постоянно, то оно должно фигурировать в роли константы. За ОЧЕНЬ редким исключением. В Sun code conventions сказано приблизительно так – "в коде могут присутствовать числа -1, 0 и 1, т.к. они могут являться инициализаторами счетчиков циклов". Хотя меня лично терзают смутные сомнения в отношении -1. Я бы сказал, что в коде могут встречаться только 0 и 1.

Что касается строк, то тут ситуация не столь однозначна. Если строка используется более одного раза – скорее всего ее стоит вынести в константы. Хотя, операции со строками вообще вещь в себе. В качестве инициализатора использовать пустую строку, на мой взгляд, чаще всего бессмысленно. Инициализатор чаще всего нужен для того, чтобы потом поменять значение переменной ОТНОСИТЕЛЬНО ТЕКУЩЕГО. В случае целого числа, скажем, когда мы объявляем переменную с инициализацией – int i = 0; – то, скорее всего, в дальнейшем мы планируем сделать что-нибудь вроде i++; Со строками операции типа "+=", во-первых, медленны, а во-вторых – требовательны к памяти. Гораздо эффективнее использовать StringBuffer, а он инициализации не требует.

В общем, в отношении строк я лично придерживаюсь следующих правил.

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

Использованию констант посвящена отдельная статья – Изменчивые постоянные, или "что такое 122?". Отдельная – потому что это не так просто, как может показаться. Существуют тонкости, о которых чаще всего не задумываются, либо вообще не знают.

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

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

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

* * *

Вот мы и добрались до финиша. Разумеется, качественный код не дает гарантии качества приложения. Но он является одной из составляющих. С качественным кодом гораздо проще сосуществовать. Поверьте опыту человека, занимающегося ОЧЕНЬ большими проектами.