Рецепты понятного кода
Одним из важнейших критериев хорошей программы, написанной на любом языке программирования, является, помимо ее правильности, то, как она написана, насколько быстро понимаемым является код этой программы. И первый параграф посвящен тому, как стоит оформлять свой код так, чтобы он был доступен для других и чтобы вам самим было легче искать в нем ошибки.
-
Используйте конструкцию else if вместо вложенных if-ов — это увеличивает понятность кода. Читать то, что написано в столбик, привычнее, чем то, что написано «лесенкой». Это является общепринятой конструкцией, и в тех языках программирования, где есть команды ветвления типа if и else, есть обязательно что-либо похожее на else if.
плохо хорошо if (...) { ... } else { if (...) { ... } else { if (...) { ... } else { ... } } }
if (...) { ... } else if (...) { ... } else if (...) { ... } else { ... }
-
Рассмотрим код:
плохо хорошо for (...; ...; ...) { if (...) { ... continue; } if (...) { ... continue; } ... }
for (...; ...; ...) { if (...) { ... } else if (...) { ... } else if (...) { ... } else { ... } }
Здесь, казалось бы, все в порядке в плане использования if, но тем не менее операторы безусловного перехода типа continue не очень приветствуются, как и опрераторы break и goto. Программа становится не структурированной и менее понятной. Поэтому continue лучше заменять конструкцией else if, если это возможно.
Однако, есть редкие случаи, в которых использование этого оператора оправдано. Скажем, при обработке какой-либо строки могут попадаться, символы, которые надо пропустить, такие как пробел. Использование continue в этом случае оправдано и по смыслу и по назначению.
Следующий отличный совет понятного кода: не надо выпендриваться и писать что-нибудь, вроде
плохо хорошо x & 64
x & (1 << 6) x & 0x40
В принципе, в приведенном примере, разница в понятности невелика, но, скажем, при такой операции: x & 0x40404040 (x & 8521760) она весьма существенна. Для единообразия лучше писать x & 0x01, нежели x & 1
-
В операциях с двоичным представлением чисел лучше использовать константы, записанные в шестнадцатеричной системе счисления, нежели в десятичной.
плохо хорошо (~0) ^ ((1 << 7) + (1 << 6))
0x3F
-
Следующий совет: сокращайте код в разумных пределах.
плохо хорошо if (a < b) return false; return true;
return a >= b;
Многих людей раздражает код, написанный слева. Зачем писать три строки, если можно написать гораздо короче.
-
Не пишите длинных строк. Причин для появления длинных строк в коде много, вот две самые распространенные:
-
if с большим условием. Разумнее разбивать условия по смыслу и писать одно под другим, даже если они и помещаются в строчку на экране вашего монитора.
плохо хорошо if ((...) && (...) && (...) && (...) && (...) && (...)) { ... }
if ((...) && (...) && (...) && (...) && (...) && (...)) { ... }
-
Комментарии. Не пишите комментарии после кода, пишите их перед кодом и разбивайте на разумные по длине подстроки. Есть еще один аргумент в пользу того, что комментарии надо писать перед кодом, например, вы хотите закомментировать красиво очень важный и нужный алгоритм, не пишите, как слева:
плохо хорошо while (...) { // ... ... // ... ... // ... ... // ... ... // ... }
// ... // ... // ... while (...) { ... ... ... ... }
Смотрится это, конечно, хорошо и читать это довольно приятно, но если вам придется вставить в алгоритм строку, или наоборот убрать, или поменять строки местами, вам придется заново форматировать комментарии и на это уйдет уйма вашего потенциального рабочего времени. Напишите комментарии перед кодом.
-
-
И последний рецепт: объединяйте тела после меток case в команде switch, если они идентичны.
плохо хорошо switch (bt) { case 0: return 0; case 1: return 0; case 2: return 0; case 4: return 0; case 8: return 0; ... case 128: return 0; case -127: return 1; case -126: return 3; }
switch (bt) { case 0: case 1: case 2: case 4: case 8: ... case 128: return 0; case -127: return 1; case -126: return 3; }
Теперь рассмотрим наиболее часто встретившиеся ошибки в последнем домашнем задании.
-
Избегайте фокусов. Старайтесь писать как можно более понятный код, даже если он получится чуть более длинным. Например, стоит задача определить есть ли в списке рядом стоящие однинаковые элементы. Решение можно реализовать двумя способами:
плохо лучше (понятнее хоть и длиннее) if (list.size() == 0) return false; int previous = list.get(0); for (int i : list) { if (i == previous) { return true; } previous = i; } return false;
Boolean first = true; int previous = 0; for (int i : list) { if (previous == i && !first) { return true; } first = false; previous = i; } return false;
Код справа хоть и более примитивен, но зато человек, который будет его читать, моментально вникнет в ваше решение.
Помните, что в Java все числа знаковые. И, например, при обращении к массиву размера 256 по переменной типа byte необходимо учитывать, что байт принимает значения в диапазоне [-128; 127].
неправильно правильно int table[256] = {...}; int count = 0; for (int i = 0; i < N; ++i) { count += table[array[i]]; }
int table[256] = {...}; int count = 0; for (int i = 0; i < N; ++i) { int bt = (array[i] >= 0) ? array[i] : 256 + array[i]; count += table[bt]; }
Т. к. обращение к элементу массива в Java — довольно дорогостоящая операция, то в правом варианте i-ый элемент массива лучше запомнить в отдельную переменную и написать так:
еще лучше int table[256 = {...}; int count = 0; for (int i = 0; i < N; i++) { int bt = array[i]; if (bt < 0) { bt += 256; } count += table[bt]; }
-
Различайте двойной >> и тройной >>> сдвиги. Двойной сдвиг сохраняет знак числа и старшие биты заполняет либо единицами, если число отрицательно, либо нулями, если число неотрицательное. Тройной сдвиг всегда заполняет старшие биты нулями. Например цикл слева при N < 0 будет работать бесконечно долго, а справа сделает столько итераций, какова длина N в двоичной системе счисления.
неправильно правильно while (N != 0) { N = N >> 1; }
while (N != 0) { N = N >>> 1; }
-
Проверяйте программы. В некоторых случаях ошибку, особенно логическую, не легко заметить без тщательного тестирования. Но в некоторых она настолько очевидна, что обнаружится при первом же запуске. Например:
1 << 7 + 1 << 6 = 1 << (7 + 1) << 6 != (1 << 7) + (1 << 6)
-
Важно также думать о крайних (вырожденных) случаях. Например, случай пустых входных данных или выход за пределы массива при проверке на корректность последовательности элементов массива (например, в зависимости от текущего байта надо дочитать еще порядка трех дополнительных).
-
Следующая проблема домашнего задания состоит в том, что многие начали использовать вместо переменной типа int объекты класса Integer, не осознавая вполне в чем между ними разница. Так вот, Integer — это класс, содержащий поле — переменную типа int. Это поле закрытое и поменять его нельзя ни напрямую, ни методами класса Integer. Оно неизменно от создания до удаления объекта сборщиком мусора. По-хорошему нужно бы было писать так:
list.add(new Integer(27))
, но за вас это делает компилятор и можно писать просто: list.add(27). Однако это не одно и то же. Компилятор делает некоторую оптимизацию: он создает массив из объектов типа Integer в диапозоне byte, т. е. [-128; 127], и когда вы пишите такой код:list.add(27)
, он просто возвращает ссылку из этого массива, не создавая новый объект. А вот если вы напишите так: list.add(new Integer(27)) — компилятор все-таки создаст новый объект, т. е. выделит под него память. Рассмотрим код из предыдущего примера:неправильно правильно Integer previous; for (Integer i : list) { if (i == previous) { ... } }
int previous; for (int i : list) { if (i == previous) { ... } }
Слева сравниваются ссылки на объекты, а не сами объекты, но при маленьких значения i (попадающих в диапозон byte) код слева будет работать правильно (т.к. равные объекты будут указывать на одни и те же элементы заранее созданного массива объектов Integer), а вот при значениях, вылезающих за границы байта, будет создаваться каждый раз новый объект для previous и ссылки будут уже не равны. Не используйте Integer, кроме как в угловых скобках.
-
И последняя самая популярная ошибка, копирование ссылки из параметров конструктора во внутреннее поле. Ясно, что после этого пользователь вашего класса может менять значение по переданной в конструктор ссылке (которая осталась у него) и значение поля вашего класса будет непредсказуемым образом меняться.
неправильно правильно IntegerHeap( ArrayList<Integer> data) { myData = data; buildHeap(); }
IntegerHeap( ArrayList<Integer> data) { mydata = new ArrayList<Integer>(); myData = data.addAll(data); buildHeap(); }
-
Если вы используете вызов функции несколько раз внутри блока от одинаковых параметров, при условии, что возвращаемое ею значение зависит только от них, то следует запоминать возвращаемое ею значение в отдельную переменную.
неэффективно эффективно if (...list.get(i)...) { ... } if (...list.get(i)...) { ... }
int iEl = list.get(i); if (...iEl...) { ... } if (...iEl...) { ... }
-
Существует такое понятие, как микроэффективность — выигрыш в небольшое количество операций на маленьком участке кода. Не всегда стоит повышать эту эффективность в ущерб «читаемости» кода, но всегда следует о ней помнить. Например, x & (1 << 7) будет работать быстрее, чем 1 & (x >> 7), т.к. компилятор заменит (1 << 7 ) на 128, отсюда правило: объединяйте константы в выражениях. Также, например, участок кода слева может работать медленнее, чем справа, т. к. перед переменной mask не указано, что она const, и ее числовое значение может не подставиться во время компиляции, а будет считываться со стека в время выполнения.
неэффективно эффективно unsigned byte mask = 0x80; ... while (...) { ... if (x & mask) { ... } ... }
const unsigned byte mask = 0x80; ... while (...) { ... // либо if (x & 0x80) if (x & mask) { ... } ... }
-
Не используйте получение элемента по индексу в цикле в LinkedList’е. Операция get(i) (как и add(i)) работает за время порядка индекса и при цикле по длине списка получится не линейное время, а квадратичное.
неэффективно эффективно for( int i = 0; i < n; ++i) { ... ... list.get(i) ...; ... }
for (int i : list) { ... ... i ...; ... }