Критерии оценки программы. Типичные случаи

Критерии оценки

При написании программ, в частности, домашнего задания, хочется уметь их оценивать.

Какие могут существовать критерии для оценки программ?

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

Каждый критерий можно разделить на две части. Рассмотрим это деление на примере эффективности:

  1. эффективность всей программы в целом зависит от использования эффективных алгоритмов и соответствующих задаче структур данных;

  2. но можно выделить также "микроэффективность" - эффективность отдельных участков кода, например, вычисление в операторе if более простого условия первым:

    if ((a != 0) || pred(a)) {
    ...
    }
    

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

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

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

Таким образом, можно выделить следующие критерии:

Уровень программы в целом

микро-уровень

Правильность

аккуратность

Эффективность

микроэффективность

 

понятность:
        простота
        читабельность
        модифицируемость
        ...

причём самыми важными из них, скорее, будут аккуратность и микроэффективность.

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

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

Понятность

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

Тема этой лекции относится как к программам вообще, так и к домашним заданиям в частности, а значит относительно понятности домашнего задания также можно дать несколько рекомендаций:

  1. Во-первых, когда вы отправляете письмо с домашним заданием, то в поле From: стоит указывать понятные данные, а именно Фамилию и Имя (например, From: Иванов Пётр), а не странный набор латинских букв, чтобы адресат не принял ваше письмо за спам. Этот совет, кстати, годится не только в случае отправки домашнего задания, но и в любом другом случае, когда вы пишете кому-либо письмо с целью, чтобы его прочитали.

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

Что касается Java, перечислим здесь некоторые соображения о стиле написания кода, которые облегчают его чтение:

    • Названия классов начинаются с большой буквы. Каждое последующее слово пишется с большой буквы (например: ComplexInteger);
    • Названия методов начинаются с маленькой буквы. Каждое последующее слово пишется с большой буквы (например: toString());
    • Названия полей начинаются с префикса my. Каждое последующее слово пишется с большой буквы (например: myValue);
    • Константы пишутся большими буквами. Слова разделяются символом '_' (например: MAX_VALUE);

  1. Локальные переменные, и не только их, желательно называть не tmp1, tmp2, а так, чтобы в названии отражался смысл этих переменных (для тех, кто не поймёт этого смысла, чтение кода не усложнится, однако остальным читать программу будет гораздо легче).

  2. Слова в идентификаторах лучше не сокращать, а писать полностью, если только они не очень длинные, или не являются общепринятыми обозначениями (как, например, вещественная и мнимая части комплексного числа: myRe и myIm).

  3. В конструкциях типа:

    public class Main {
    ...
    
    public void length() {
    ...
    
    if (...) {
    ...
    
    открывающая фигурная скобка ставится на той же строчке, что и начало конструкции.

  4. В различного рода выражениях после знаков препинания, перед и после знаков арифметических операций ставится пробел:

    плохо
    foo(a,b);
    
    a=x+y*2-b/c;
    
    for(i=0;i<10;++i) {     
    
    хорошо
    foo(a, b);
    
    a = x + y * 2 - b / c;
    
    for (i = 0; i < 10; ++i) {
    

Микроэффективность

  1. Рассмотрим случай, когда требуется перебрать несколько взаимоисключающих вариантов: (i == 1, i == -1, i == 0)

  2. Пример 1.1. "Плохо":

    if (i == 1) {
    ...
    }
    if (i == -1) {
    ...
    }
    if (i == 0) {
    ...
    }
    ...
    

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

    Пример 1.2. "Лучше":

    if (i == 1) {
    ...
    } else {
        if (i == -1) {
        ...
        } else {
            if (i == 0) {
            ...
            } else {
            ...
            }
        }
    }
    

    В этом случае, однако, код очень неудобно читать. Существует гораздо более удобная форма записи:

    Пример 1.3. "Ещё лучше":

    if (i == 1) {
    ...
    } else if (i == -1) {
    ...
    } else if (i == 0) {
    ...
    } else {
    ...
    }
    

    В такой форме явно демонстрируется равноправность всех вариантов.

  3. Довольно интересен пример, в котором проверяется, делится ли одно целое комплексное число на другое (целое комплексное число - число с целыми вещественной и мнимой частями):

    таким образом, необходимо проверить три условия:
    c2 + d2 != 0
    (ac + bd) % (c2 + d2) == 0
    (bc - ad) % (c2 + d2) == 0

    • Явным образом записывать эти условия не стоит (пример 2.1):

      Пример 2.1. Первый вариант проверки

      if ((c*c + d*d) != 0 && 
          ((a*c + b*d) % (c*c + d*d)) == 0 &&
          ((b*c - a*d) % (c*c + d*d)) == 0) {
      ...
      }
      так как в этом случае выражение (c2 + d2) вычисляется три раза, а код становится менее понятным.

    • Гораздо лучше было бы написать так (пример 2.2):

      Пример 2.2. Второй вариант проверки

      int tmp1 = c*c + d*d;
      int tmp2 = a*c + b*d;
      int tmp3 = b*c - a*d;
      
      if (tmp1 != 0 && 
          (tmp2 % tmp1 == 0 &&
          (tmp3 % tmp1) == 0) {
      ...
      }
    • Но это всё равно не самый оптимальный вариант, потому что в случае равенства (c2 + d2) нулю выполняется ненужная работа по вычислению значений переменных tmp2 и tmp3. В следующем примере данный недостаток исправлен (пример 2.3):

      Пример 2.3. Улучшенный вариант проверки

      int abs2 = c*c + d*d; // Квадрат абсолютного значения - более хорошее имя для переменной
      if (abs2 == 0) {
          return false;
      }
      int tmp1 = a*c + b*d;
      int tmp2 = b*c - a*d;
      if ((tmp1 % abs2) == 0 && (tmp2 % abs2) == 0) {
      ...
      }
      
    • Таким образом, последний вариант кода хорошо удовлетворяет критериям понятности и аккуратности, хотя в данном случае понятность несколько противоречит эффективности, и было бы можно убрать временные переменные tmp1 и tmp2 (при этом не будет тратиться время на выделение места для переменных на стеке). К тому же, переменная abs2 используется как константа, и правильнее было бы описать её как final (пример 2.4):

      Пример 2.4. Окончательный вариант проверки

      final int abs2 = c*c + d*d; 
      if (abs2 == 0) {
          return false;
      }
      if (((a*c + b*d) % abs2) == 0 && ((b*c - a*d) % abs2) == 0) {
      ...
      }
      

Аккуратность

Случаев, в которых нужно заботиться об аккуратности, существует очень много: так, в частности, в предыдущем примере неаккуратной была бы проверка на делимость без рассмотрения случая c2 + d2 == 0.

  1. Есть ещё одно замечание для предыдущего примера: в строчке final int abs2 = ... может возникнуть переполнение. Во избежание этого лучше объявить abs2 как long (пример 3.1):

    Пример 3.1. Дополнение к примеру 2.4, исключающее возможность переполнения

    final long abs2 = (long)c*c + (long)d*d; 
    if (abs2 == 0) {
        return false;
    }
    if (((a*c + b*d) % abs2) == 0 && ((b*c - a*d) % abs2) == 0) {
    ...
    }
    
  2. Если бы вы захотели написать метод, отображающий целое комплексное число на экране, то удачной с точки зрения аккуратности была бы реализация, учитывающая множество частных случаев (пример 3.2):

    Пример 3.2. Обработка частных случаев при печати целых комплексных чисел

    Число		Отображение
    0 + 0i	   ->	0
    2 + 1i	   ->	2 + i
    3 - 1i	   ->	3 - i
    0 - 1i	   ->	-i
    5 + 0i	   ->	5
    ...
    

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

Ещё несколько типичных случаев

  1. Замечательный случай, когда для получения одного результата можно написать код по-разному, причём некоторым людям более понятен первый способ, а некоторым - второй (пример 4.1):

    Пример 4.1. Субъективность понятности кода

    I вариант:
    if (...) {
        return true;
    } else {
        return false;
    }
    
    II вариант:
    return (...);
    

  2. В примерах 1.1 - 1.3 с технической точки зрения возможно использование конструкции switch (пример 4.2):

    Пример 4.2. Использование switch для выбора вариантов

    switch (i) {
    case 1:
        ...
        break;
    case -1:
        ...
        break;
    case 0:
        ...
        break;
    default:
        ...
        break;
    }

    Однако употребление switch в данном примере выглядит не самым удачным по нескольким причинам:

    • Во-первых, switch - это довольно неестественная конструкция для такого-рода ситуаций (switch используется в случаях, когда нужно перебрать конечное число значений из некоторого небольшого множества, а в данном примере возможных вариантов огромное количество).
    • Во-вторых, этот код не самый оптимальный с точки зрения модифицируемости: не получится легко поменять тип переменной i не только на float, но даже на long (типы long и float не могут использоваться в конструкции switch). К тому же, при использовании switch невозможно реализовать выделение отдельных случаев (i > 1) и (i < -1).

  3. Иногда ради понятности и/или однообразия кода в ущерб эффективности могут употребляться конструкции типа:

    Пример 4.3. Использование неестественных конструкций ради понятности кода

    if (minPixels <= 640*480) {
    ...

    или

    if (...) {
        return "" + i;
    } else if (...) {
        return "" + j;
    } else
        return "" + 0;
    }