Рецепты понятного кода

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

  1. Используйте конструкцию else if вместо вложенных if-ов — это увеличивает понятность кода. Читать то, что написано в столбик, привычнее, чем то, что написано «лесенкой». Это является общепринятой конструкцией, и в тех языках программирования, где есть команды ветвления типа if и else, есть обязательно что-либо похожее на else if.

    плохохорошо
    if (...) {
      ...
    } else {
      if (...) {
        ...
      } else {
        if (...) {
          ...
        } else {
          ...
        }
      }
    }
    
    if (...) {
      ...
    } else if (...) {
      ...
    } else if (...) {
      ...
    } else {
      ...
    }
    
  2. Рассмотрим код:

    плохохорошо
    for (...; ...; ...) {
      if (...) {
        ...
        continue;
      }
      if (...) {
        ...
        continue;
      }
      ...
    }
    
    for (...; ...; ...) {
      if (...) {
        ...
      } else if (...) {
        ...
      } else if (...) {
        ...
      } else {
        ...
      }
    }
    

    Здесь, казалось бы, все в порядке в плане использования if, но тем не менее операторы безусловного перехода типа continue не очень приветствуются, как и опрераторы break и goto. Программа становится не структурированной и менее понятной. Поэтому continue лучше заменять конструкцией else if, если это возможно.

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

  3. Следующий отличный совет понятного кода: не надо выпендриваться и писать что-нибудь, вроде

    плохохорошо
    x & 64
    
    x & (1 << 6)
    x & 0x40
    

    В принципе, в приведенном примере, разница в понятности невелика, но, скажем, при такой операции: x & 0x40404040 (x & 8521760) она весьма существенна. Для единообразия лучше писать x & 0x01, нежели x & 1

  4. В операциях с двоичным представлением чисел лучше использовать константы, записанные в шестнадцатеричной системе счисления, нежели в десятичной.

    плохохорошо
    (~0) ^ ((1 << 7) + (1 << 6))
    
    0x3F
    
  5. Следующий совет: сокращайте код в разумных пределах.

    плохохорошо
    if (a < b)
      return false;
    return true;
    
    return a >= b;
    

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

  6. Не пишите длинных строк. Причин для появления длинных строк в коде много, вот две самые распространенные:

    1. if с большим условием. Разумнее разбивать условия по смыслу и писать одно под другим, даже если они и помещаются в строчку на экране вашего монитора.

      плохохорошо
      if ((...) && (...) && (...) && (...) && (...) && (...)) {
        ...
      }
      
      if ((...) &&
          (...) &&
          (...) &&
          (...) &&
          (...) &&
          (...)) {
        ...
      }
      
    2. Комментарии. Не пишите комментарии после кода, пишите их перед кодом и разбивайте на разумные по длине подстроки. Есть еще один аргумент в пользу того, что комментарии надо писать перед кодом, например, вы хотите закомментировать красиво очень важный и нужный алгоритм, не пишите, как слева:

      плохохорошо
      while (...) { // ...
        ...         // ...
        ...         // ...
        ...         // ...
        ...         // ...
      }
      
      // ...
      // ...
      // ...
      while (...) {
        ...        
        ...        
        ...        
        ...        
      }
      

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

  7. И последний рецепт: объединяйте тела после меток 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;
    }
    

Теперь рассмотрим наиболее часто встретившиеся ошибки в последнем домашнем задании.

  1. Избегайте фокусов. Старайтесь писать как можно более понятный код, даже если он получится чуть более длинным. Например, стоит задача определить есть ли в списке рядом стоящие однинаковые элементы. Решение можно реализовать двумя способами:

    плохолучше (понятнее хоть и длиннее)
    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;
    

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

  2. Помните, что в 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];
    }
    
  3. Различайте двойной >> и тройной >>> сдвиги. Двойной сдвиг сохраняет знак числа и старшие биты заполняет либо единицами, если число отрицательно, либо нулями, если число неотрицательное. Тройной сдвиг всегда заполняет старшие биты нулями. Например цикл слева при N < 0 будет работать бесконечно долго, а справа сделает столько итераций, какова длина N в двоичной системе счисления.

    неправильноправильно
    while (N != 0) {
      N = N >> 1;
    }
    
    while (N != 0) {
      N = N >>> 1;
    }
    
  4. Проверяйте программы. В некоторых случаях ошибку, особенно логическую, не легко заметить без тщательного тестирования. Но в некоторых она настолько очевидна, что обнаружится при первом же запуске. Например:

    1 << 7 + 1 << 6 = 1 << (7 + 1) << 6 != (1 << 7) + (1 << 6)
    
  5. Важно также думать о крайних (вырожденных) случаях. Например, случай пустых входных данных или выход за пределы массива при проверке на корректность последовательности элементов массива (например, в зависимости от текущего байта надо дочитать еще порядка трех дополнительных).

  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, кроме как в угловых скобках.

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

    неправильноправильно
    IntegerHeap( ArrayList<Integer> data) {
        myData = data;
        buildHeap();
    }
    
    IntegerHeap( ArrayList<Integer> data) {
        mydata = new ArrayList<Integer>();
        myData = data.addAll(data);
        buildHeap();
    }
    
  8. Если вы используете вызов функции несколько раз внутри блока от одинаковых параметров, при условии, что возвращаемое ею значение зависит только от них, то следует запоминать возвращаемое ею значение в отдельную переменную.

    неэффективноэффективно
    if (...list.get(i)...) {
         ...
    }
    if (...list.get(i)...) {
         ...
    }
    
    int iEl = list.get(i);
    
    if (...iEl...) {
         ...
    }
    if (...iEl...) {
         ...
    }
    
  9. Существует такое понятие, как микроэффективность — выигрыш в небольшое количество операций на маленьком участке кода. Не всегда стоит повышать эту эффективность в ущерб «читаемости» кода, но всегда следует о ней помнить. Например, 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) {
        ...
      }
      ...
    }
    
  10. Не используйте получение элемента по индексу в цикле в LinkedList’е. Операция get(i) (как и add(i)) работает за время порядка индекса и при цикле по длине списка получится не линейное время, а квадратичное.

    неэффективноэффективно
    for( int i = 0; i < n; ++i) {
         ...
    ... list.get(i) ...;
    ...
    }
    
    for (int i : list) {
         ...
         ... i ...;
         ...
    }