Стиль программирования

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

Для компилятора не имеет значения как оформлен текст программы. Например, следующая программа является корректной с точки зрения компилятора языка C, но сможете ли вы разобраться в том, что она делает?

main(_,l)char**l;{6*putchar(--_%20?_+_/21&56>_?strchr(1[l],_^"pt`u}rxf~c{wk~zyHHOJ]QULGQ[Z"[_/2])?111:46:32:10)^_&&main(2+_,l);}

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

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

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

правильно работающие программы, которые плохо оформлены, не принимаются.

Форматирование кода

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

Отступы и расположение фигурных скобок

Отступы используются для визуального выделения структуры программы. Операторы, которые составляют тело функции, цикла или условного оператора, принято сдвигать вправо относитльно заголовка функции, цикла или условного оператора. В приведенном ниже примере (справа) сразу видно, какие операторы выполняются в теле цикла while. Также явно видно где заканчивается функция main.

Плохо

int main()    {
int k=100;
while(k>0){
    k=k-1;
if((k%7)==1){printf("k=%d\n",k);}}
     return 0;

}

Лучше

int main() {
    int k = 100;
    while(k > 0) {
        k = k-1;
        if((k % 7) == 1)
            printf("k=%d\n", k);
    }
    return 0;
}

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

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

Пробелы внутри одного оператора

В каких случаях следует ставить пробелы в рамках одной строки (одного оператора)?

Плохо

int validate_values(double x,double y,double a){ // нет пробелов
    double k = x/ y-a; // выглядит как ошибка в x/(y-a)
    double dx=0 ; // пробел перед ;
    if(k< 0||x <  y){  // нет пробела до < и лишний пробел после
        return -1;
    }
    for(dx=(y-x)/10;x<y;x+=dx) { // все сливается
        if(compute(1,2.1,x,3-x,1.,0.1,.3)<0){ // аргументы не видны
            return - 1; // пробел после унарной операции
        }
    }
    return 0;
}

Лучше

void validate_values(double x, double y, double a) {
    double k = x/y - a;
    double dx = 0;
    if(k < 0 || x < y) {
        return -1;
    }
    for(dx = (y-x)/10; x < y; x += dx) {
        if(compute(1, 2.1, x, 3-x, 1., 0.1, .3) < 0) {
            return -1;
        }
    }
    return 0;
}

Длинные строки

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

Вызов функции

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

...
double velocity = 0;
velocity = compute_velocity(convert_to_kilometers(miles_travelled),
                            end_time - start_time);
distance = compute_distance(x1, y1, z1,
                            x2, y2, z2);

Цикл со сложными условиями

Управляющая конструкция цикла for включает инициализацию переменных, проверку условия продолжения цикла и оператор, который выполняется после каждой итерации цикла (инкремент). Эти выражения могут быть достаточно длинными. Разрыв строки можно ставить после символа точка с запятой, который разделяет эти три части.

double process_points(double points[], int number_of_points, double border, double slope) {
    int point_number;
    for(point_number = find_starting_point(points, slope);
        point_number < number_of_points && point[point_number] < border;
        point_number++) {
        /* ... */
    }
    return 0;
}

Логические и арифметические выражения

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

Имена переменных

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

Символьные имена для числовых констант

Избегайте явного использования числовых констант. Используйте вместо них переменные, директивы #define, перечисления enum.

Плохо

int buffer[256];
// ...
void process_command(int command) {
    switch(command) {
        case 1:
            for(int k = 0; k < 256; k++)
                buffer[k] = 0;
            break;
        case 2:
            if(buffer[0] == 0) {
                // ...
            }
    }
}

Лучше

#define BUFFER_SIZE 256
typedef enum {CMD_CLEAR = 1, CMD_PRINT = 2} CommandType;
// ...
int buffer[BUFFER_SIZE];
// ...
void process_command(CommandType command) {
    const int blank_value = 0;
    switch(command) {
        case CMD_CLEAR:
            for(int k = 0; k < BUFFER_SIZE; k++)
                buffer[k] = blank_value;
            break;
        case CMD_PRINT:
            if(buffer[0] == blank_value) {
                // ...
            }
    }
}

Посмотрим на исходный вариант этого фрагмента программы (плохой). Здесь используются константы 256, 0, 1 и 2 для обозначения длины массива buffer, начального значения элемента этого массива, команды ОЧИСТИТЬ и команды ПЕЧАТЬ, соответственно. Может ли читатель, глядя на левый код, догадаться, что константа 1 означает команду ОЧИСТИТЬ? Без анализа кода – нет. Можно ли сказать, какие значения допустимы для параметра command функции process_command? Без анализа кода этого не сделать, так как аргумент объявлен целочисленным. Какой смысл имеет константа 0, которая четыре раза появляется в этом коде?

Улучшенный вариант этого фрагметна отличается следующим. Во-первых, размер буфера определен директивой #define. В случае необходимости изменения размера буфера это можно сделать исправив одну строчку – директиву #define. Изменение будет автоматически применено во всех местах, где используеся имя BUFFER_SIZE. В исходном варианте программы потребовалось бы просмотреть весь ее код и заменить 256 на новое значение. В общем случае константа 256 может появляться много раз и в нескольких смыслах, так что заменять нужно только некоторые вхождения, что обычно является причиной возникновения сложно обнаруживаемых ошибок, когда в некоторых местах программы остается старое значение. Во-вторых, для обозначения команд введен тип-перечисление CommandType. Это позволяет объявить формальный параметр функции с правильным типом, который допускает только два значения, и использовать символьные имена CMD_CLEAR и CMD_PRINT вместо соответствующих числовых констант. В-третьих, "пустому" значению 0 присвоено явное символьное обозначение blank_value. Это дает возможность читателю понять смысл константы 0.

Возможность последовательного чтения программы

Человеку удобно читать программу последовательно сверху-вниз без необходимости частых переходов, особенно, возвратов назад. Языки высокого уровня проектируются так, чтобы программист мог написать программу в таком стиле. Часто для этого в язык вводится избыточная конструкция. Например, оператор цикла for в языке C специально сделан так, чтобы собрать в одной строке действия, ключевые для понимания логики работы цикла (инициализацию, условие и инкремент), но он не является необходимым. Действительно, любой цикл можно реализовать используя только операторы if и goto:

Плохо

    int k = 0;              // Инициализация
loop_start:
    if(k < 100) {           // Проверка условия
        printf("%d", k);
        if((k % 3) == 0) {
            printf(" делится на три!");
        }
        printf("\n");
        k += 2;             // Изменение управляющей переменной цикла
        goto loop_start;    // Переход к следующей итерации
    }

Лучше

for(int k = 0; k < 100; k += 2) {
    printf("%d", k);
    if((k % 3) == 0) {
        printf(" делится на три!");
    }
    printf("\n");
}

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

Плохо

void replace_by_products(double *data, int length) {
    double temp;     // Начальное значение не присвоено; запомнили, что в переменной "мусор".
    for(int k = 0; k < length; k++) {
        if(k > 1) {
            data[k-2] = temp * data[k]; // Что!? Умножаем элемент массива на "мусор"?
        }
        temp = data[k]; // ... Нет, оказывается, все нормально. Переменная получает корректное значение,
                        // которое сохраняется до следующей итерации цикла. А первую итерацию, когда значение
                        // переменной temp было еще неопределено, мы пропустили с помощью if.
    }
}
Правильное форматирование кода в плане отступов и пробелов не делает приведенный выше фрагмент хорошей программой. Для понимания ее корректности нужно прочитать цикл до самого конца, заметить, что значение переменной "передается" между итерациями и подумать о том, чему будет равно значение temp на итерации k. Психологическая сложность состоит в том, что в операторе присваивания temp = data[k]; стоит переменная k, но в том месте, где переменная используется data[k-2] = temp * data[k];, значение k уже изменилось. Получается. что читателю нужно помнить, что в момент использования temp ее значение фактически соответствует data[k-1], а не data[k] как это написано ниже.

Конечно, приведенный пример очень простой и в нем легко разобраться, но, как было показано в начале этой странице, при некотором старании можно и однострочную функцию сделать абсолютно непонятной. Нужно стремиться к тому, чтобы все переменные, которые используются на очередной итерации цикла, получали свои значения в начале итерации. В приведенном примере от переменной temp можно было вообще отказаться, если заменить data[k-2] = temp * data[k]; на data[k-2] = data[k-1] * data[k];.

Удаление лишних условий

Не пишите лишних else и не дублируйте действия в обеих ветках условных операторов.

Плохо

int do_something(double value) {
    if(value < 0) {
        return -1;
    } else {
        if(value > 1.5) {
            // что-то делаем
            printf("value=%f\n", value); // ... и печатаем value
        } else {
            // делаем что-то другое
            printf("value=%f\n", value);  // ... и печатаем value
        }
    }
    return 0;
}

Лучше

int do_something(double value) {
    if(value < 0) {
        return -1;
    }
    // else не нужен - return заканчивает выполнение функции.
    if(value > 1.5) {
        // что-то делаем
    } else {
        // делаем что-то другое
    }
    // Печать выносим из условия. Это эквивалентное преобразование.
    printf("value=%f\n", value);
    return 0;
}