Лучшие приемы программирования на C
Шив Дутта, старший инженер-программист, IBM
Стили и нормы программирования
- Необходимо использовать стиль программирования, который делает код читабельным и понятным. Несмотря на то, что некоторые разработчики имеют свой собственный стиль программирования или используют стиль программирования, принятый в их компании, хорошим тоном считается следовать стилю программирования Кернигана и Ритчи (Kernighan и Ritchie), используемому подавляющим большинством программистов на C. Однако, чересчур увлекшись, легко прийти к чему-нибудь такому:
int i;main(){for(;i["]<i;++i){--i;}"];read('-'-'-',i+++"hell\
o, world!\n",'/'/'/'));}read(j,i,p){write(j/p+p,i---j,i/i); |
Dishonorable mention, Чемпионат по самому непонятному коду на C (Obfuscated C Code Contest), 1984 г. Автор кода неизвестен.
- Всегда в коде можно увидеть главную функцию, называемую
main() . По стандарту ANSI эта функция определяется как int main(void) (если не нужно обрабатывать аргументы командной строки) или как int main( int argc, char **argv ) . Не-ANSI компиляторы могут пропускать объявление void или составлять список имен переменных и следовать их объявлениям.
- Отступы
Необходимо использовать вертикальные и горизонтальные отступы. Количество и расположение отступов и пробелов должно отражать структуру кода.
Длинная строка условного оператора должна быть разбита на несколько строк. Например:
if (foo->next==NULL && number < limit && limit <=SIZE
&& node_active(this_input)) {... |
будет лучше выглядеть как:
if (foo->next == NULL
&& number < limit && limit <= SIZE
&& node_active(this_input))
{
... |
Точно так же сложные циклы for должные быть разделены на несколько строк:
for (curr = *varp, trail = varp;
curr != NULL;
trail = &(curr->next), curr = curr->next )
{
... |
Другие сложные выражения, такие как использующие оператор ?: тоже лучше разделить на несколько строк:
z = (x == y)
? n + f(x)
: f(y) - n;
|
- Комментарии
Комментарии должны описывать то, что происходит, каким образом это происходит, что означает тот или иной параметр, какие глобальные переменные используются, а также любые ограничения и возможные ошибки. Однако необходимо избегать необязательных комментариев. Если код понятен и используются хорошие имена переменных, то, возможно, не потребуется дополнительных пояснений. Так как комментарии не проверяются компилятором, то не гарантируется, что они правильные. Комментарии, которые не согласуются с кодом, вредны. Слишком большое число комментариев приводит к беспорядку в коде.
Такой стиль комментирования является избыточным:
i=i+1; /* добавляем 1 к i */
|
Хорошо видно, что переменная i увеличивается на единицу. И еще более плохой вариант показать это так:
/************************************
* *
* добавляем 1 к i *
* *
************************************/
i=i+1; |
- Правила наименования
Имена с ведущими или завершающими знаками подчеркивания предназначены только для системы целей и не должны использоваться для каких-либо пользовательских имен переменных. Правила определяют следующие требования:
- Константы
#define должны записываться ЗАГЛАВНЫМИ символами.
- Константы
enum должны начинаться с заглавного символа или записываться полностью ЗАГЛАВНЫМИ символами.
- Слова
function , typedef и имена переменных, так же как и struct , union и enum должны быть в нижнем регистре.
Для понятности необходимо избегать имен, различающихся только регистром, например, foo и Foo . Точно так же лучше избегать одновременного использования имен foobar и foo_bar . Необходимо избегать любых имен, которые похожи друг на друга. На многих клавиатурах и во многих шрифтах l , 1 и I выглядят очень похоже. Переменная с именем l , в частности, плоха потому, что похожа на константу 1 .
- Имена переменных
При выборе имени переменной не так важна длина имени, как понятность. Длинные имена могут назначаться глобальным переменным, которые редко используются, но индексы массивов, появляющиеся в каждой строке цикла, не должны быть значительно сложнее, чем i . Использование index или elementnumber не только усложняет набор, но и может сделать менее понятными детали вычислений. С длинными именами иногда сложнее понять, что именно происходит в коде. Легко сравнить:
for(i=0 to 100)
array[i]=0 |
и
for(elementnumber=0 to 100)
array[elementnumber]=0;
|
- Имена функций
Имена должны отражать то, что делают функции и что они возвращают. Функции используются в выражениях, часто в условных операторах if , поэтому они должны читаться соответственно. Например:
непонятно, так как не говорит о том, возвращает ли функция TRUE при прохождении проверки или наоборот; использование вместо этого:
делает все понятным.
- Объявление переменных
Все объявления внешних переменных должны предваряться ключевым словом extern .
Обозначение указателя, * , должно сопровождать имя переменной, а не ее тип:
вместо
Второй пример объявления переменных не является неправильным, но могут возникнуть сомнения из-за того, что t и u не объявлены как указатели.
- Заголовочные файлы
Заголовочные файлы должны быть функционально организованы, т. е. объявления для различных подсистем должны содержаться в различных заголовочных файлах. Также объявления, которые являются платформозависимыми, должны быть вынесены в отдельный заголовочный файл.
Следует избегать имен заголовочных файлов, совпадающих с именами стандартных библиотек. Строка #include "math.h'' включает заголовочный файл стандартной библиотеки math, если файл с таким именем не будет найден в текущем каталоге. Если такое поведение - именно то, что нужно, то лучше оставить соответствующий комментарий.
Наконец, использование абсолютных путей для заголовочных файлов - не самая лучшая идея. Опция компилятора C include-path (-I на большинстве систем) - это предпочтительный метод обработки внешних библиотек и заголовочных файлов; он позволяет изменить структуру каталогов без необходимости изменения исходных кодов.
scanf
Не следует использовать scanf в серьезных приложениях. Обработка ошибок в этой функции неадекватна. Рассмотрим такой пример:
#include <stdio.h>
int main(void)
{
int i;
float f;
printf("Enter an integer and a float: ");
scanf("%d %f", &i, &f);
printf("I read %d and %f\n", i, f);
return 0;
} |
Запустим тест:
Enter an integer and a float: 182 52.38
I read 182 and 52.380001
Теперь другой тест:
Enter an integer and a float: 6713247896 4.4
I read -1876686696 and 4.400000
++ и --
При применении операций инкремента или декремента к переменной эта переменная не должна появляться в выражении более одного раза, так как итог в этом случае зависит от компилятора. Не следует писать код, который полагается на порядок обработки или особенности компилятора:
int i = 0, a[5];
a[i] = i++; /* присваивание значения a[0]? или a[1]? */
|
- Нельзя позволять себе видеть то, чего на самом деле нет
Рассмотрим следующий пример:
while (c == '\t' // c = ' ' // c == '\n')
c = getc(f);
|
На первый взгляд такой оператор while выглядит корректным кодом на C. Однако использование оператора присваивания вместо оператора сравнения приводит к появлению синтаксически некорректного кода. Так как приоритет оператора = является наименьшим, то данное выражение будет интерпретировано следующим образом (скобки добавлены для наглядности):
while ((c == '\t' // c) = (' ' // c == '\n'))
c = getc(f); |
Левая часть оператора присваивания:
не приводит к появлению корректного значения. Если переменная c содержит символ табуляции, то результат TRUE и дальнейшие вычисления не выполняются, а TRUE не может быть левой частью оператора присваивания.
- Явно выраженные намерения
При написании кода, который может быть интерпретирован как что-то другое, необходимо заключать этот код в скобки, чтобы быть уверенным, что намерения выражены явно. Это поможет понять намерения разработчика при последующих обращениях к коду, а также помогает в сопровождении кода.
Иногда можно разрабатывать код, который предупреждает возможные ошибки. Например, можно ставить константы в левую часть оператора сравнения, т. е. вместо:
while (c == '\t' // c == ' ' // c == '\n')
c = getc(f); |
можно написать так:
while ('\t' == c // ' ' == c // '\n' == c)
c = getc(f); |
В этом случае компилятор выдаст предупреждение:
while ('\t' = c // ' ' == c // '\n' == c)
c = getc(f); |
Такой стиль программирования позволяет компилятору находить потенциальные проблемы; пример кода выше неправилен, так как пытается присвоить значение для \t .
- Ошибки из-за специфики реализации языка программирования
Реализации языка C могут отличаться в некоторых аспектах. Необходимо иметь представление о той части языка, которая совпадает во всех реализациях. Зная это, значительно проще портировать программу на другую систему или другой компилятор, что уменьшает шансы столкнуться со спецификой компилятора. Для примера рассмотрим следующую строку:
Выражение будет интерпретироваться по правилу максимального оператора. Если комментарии могут быть вложенными, то интерпретация будет следующей:
Паре символов /* соответствует пара символов */ , поэтому выражение равно 1 . Если комментарии не вкладываются, то на некоторых системах /* в комментариях будет проигнорировано. На некоторых компиляторах будет также выдано предупреждение для вложенной последовательности /* . В любом случае выражение будет интерпретировано следующим образом:
2 * 1 равно 2 .
- Сбрасывание буфера на диск
Когда приложение завершается некорректно, окончание его вывода обычно теряется. Приложение может не успеть полностью завершить вывод. Часть информации может оставаться в памяти и уже не будет записана в вывод. В некоторых системах такой незавершенный вывод может достигать нескольких страниц памяти.
Потеря вывода может также привести к мысли, что программа завершилась ошибочно гораздо раньше, чем это произошло на самом деле. Способ решения такой проблемы состоит в организации принудительного вывода, особенно при отладке. Конкретная реализация этого отличается для различных систем, но обычно выглядит так:
setbuf(stdout, (char *) 0); |
Это выражение должно быть выполнено перед записью в stdout . В идеале этот код должен помещаться в начале функции main .
getchar() - макрос или функция?
Следующая программа выводит свои входные данные:
#include <stdio.h>
int main(void)
{
register int a;
while ((a = getchar()) != EOF)
putchar(a);
}
|
Если удалить включение заголовочного файла #include , то это вызовет ошибку компиляции, так как значение EOF не объявлено.
Программу можно переписать следующим образом:
#define EOF -1
int main(void)
{
register int a;
while ((a = getchar()) != EOF)
putchar(a);
}
|
Это будет работать на большинстве систем, но на некоторых может быть значительно медленнее.
Так как вызов функции обычно занимает довольно много времени, getchar часто реализуют в виде макроса. Этот макрос определен в stdio.h , поэтому когда #include <stdio.h> удаляется, компилятор не знает, что такое getchar . На некоторых системах полагается, что getchar - это функция, возвращающая int .
В действительности многие реализации компиляторов языка C имеют свои стандартные функции getchar , частично в целях защиты от таких оплошностей. Таким образом, ситуация, когда включение #include <stdio.h> пропущено, влечет использование компилятором собственной версии функции getchar . Дополнительные вызовы этой функции делают программу медленнее. То же самое верно и для putchar .
- Пустой указатель
Пустой указатель не указывает ни на какой объект. Неправильно использовать пустой указатель для любых целей, кроме присваивания и сравнения.
Никогда не следует переопределять значение NULL , которое всегда должно равняться нулю. Пустой указатель любого типа должен всегда сравниваться с константным нулем, поскольку явное сравнение с переменной, имеющей значение нуль, или любой ненулевой константой будет платформозависимым.
Переход по пустому указателю может вызвать странные эффекты.
- Что означает
a+++++b ?
Единственный правильный способ интерпретации этого выражения такой:
Однако правило длинного оператора предписывает разбить выражение следующим образом:
Это синтаксически неверно, такой код эквивалентен строке:
Но результат a++ не является lvalue и, следовательно, не может быть операндом для ++ . Таким образом, правила для разрешения логических двусмысленностей не могут в этом примере привести к синтаксически верной конструкции. На практике, конечно, лучший способ избежать таких конструкций - это полная уверенность в том, что код интерпретируется однозначно. Конечно, добавление пробелов помогает компилятору понять цель оператора, но предпочтительнее (в перспективе сопровождения кода) разбить конструкцию на две строки:
- Осторожное обращение с функциями
Функции обеспечивают наиболее общее структурирование кода на C. Они должны использоваться для решения проблемы "сверху вниз" - для разбиения задачи на ряд более мелких подзадач до тех пор, пока подзадача не будет легко решаться. Это помогает реализовать модульность и упростить документирование программы. Кроме того, программы, составленные из большого числа маленьких функций, значительно легче для отладки.
Необходимо приводить все аргументы функций к нужному типу, если это не было сделано раньше, даже если точно известно, что компилятор осуществляет необходимое приведение типов. Делая приведение типа вручную, программист явно обозначает свои намерения и получит правильный результат при портировании приложения на другую платформу.
Если заголовочные файлы не объявляют тип возвращаемого значения библиотечных функций, необходимо сделать это самостоятельно. Окружив объявления конструкцией #ifdef/# , можно упростить портирование своего кода на другую платформу.
Прототипы функций используются для того, чтобы сделать код более устойчивым, а приложение - быстрым.
- "Висячий"
else
Нужно опасаться проблемы "висячего" else , если нет полной уверенности в правильности конструкции:
if (a == 1)
if (b == 2)
printf("***\n");
else
printf("###\n");
|
Правило гласит, что else принадлежит ближайшему if . В случае, если возникают сомнения или потенциальная двусмысленность, то лучше добавить фигурные скобки для обозначения структуры кода.
- Границы массива
Необходимо проверять границы всех массивов, включая строки, так как сегодняшнее fubar ' может стать завтра floccinaucinihilipilification . В надежном программном обеспечении не используется gets() .
Тот факт, что в C элементы нумеруются с нуля, делает более вероятными ошибки подсчета. Однако требуются некоторые усилия на изучение того, как уберечься от них.
- Пустой оператор
Пустой оператор цикла for или while должен размещаться на отдельной строке и комментироваться так, чтобы было понятно, что в этом месте действительно пустой оператор, а не пропущенный код:
while (*dest++ = *src++)
; /* VOID */
|
- Проверка выражений на истинность
Не нужно оставлять по умолчанию проверку на ненулевое значение, т. е.:
лучше, чем
даже если FAIL имеет значение 0 , которое C рассматривает как ложь (конечно, здесь нужно соблюдать баланс с такими конструкциями как, например, показанная в разделе "Имена функций"). Явное значение поможет избежать ошибок, если вдруг кто-то решит, что при неудачном завершении должно возвращаться значение -1 вместо 0 .
Частые затруднения вызывает функция проверки равенства строк strcmp , так как нет единого значения, означающего, что строки неравны. Предпочтительный вариант - определение в этом случае макроса STREQ :
#define STREQ(str1, str2) (strcmp((str1), (str2)) == 0)
|
Использовать этот макрос можно в операторах следующего вида:
If ( STREQ( inputstring, somestring ) ) ...
|
Таким образом, функция получает желаемое поведение (не требуется переписывать или переопределять стандартные библиотечные функции типа strcmp() ).
Не следует сравнивать логические выражения с 1 (TRUE , YES и другими); вместо этого нужно проверять на равенство 0 (FALSE , NO и так далее). Большинство функций гарантируют возвращение 0 в случае неудачного завершения, и возвращение лишь ненулевого значения в случае удачного завершения. Таким образом,
лучше переписать так:
- Вложенные операторы
А сейчас - время для разговора о вложенном операторе присваивания. В некоторых конструкциях нет лучшего способа присваивания, хотя он и влечет увеличение кода в операторе и ухудшение читабельности:
while ((c = getchar()) != EOF) {
process the character
} |
Использование вложенного оператора присваивания для улучшения производительности возможно. Однако необходимо искать компромисс между увеличением скорости и усложнением сопровождения кода, которое возникает при использовании вложенных присваиваний в неподходящем месте. Например:
не должно заменяться на:
даже если последний вариант сможет сберечь один цикл. В долговременной перспективе разница во времени между двумя этими вариантами будет уменьшаться из-за использования компьютерной оптимизации, в то время как разница во времени, необходимом для сопровождения кода, будет увеличиваться.
- Оператор
goto
goto необходимо использовать крайне умеренно. Один из случаев, когда этот оператор полезен - это необходимость прервать многоуровневый оператор switch , for или while , хотя такая необходимость может свидетельствовать о том, что внутреннюю конструкцию лучше вынести в отдельный цикл.
for (...) {
while (...) {
...
if (wrong)
goto error;
}
}
...
error:
print a message
|
Когда необходимо применять оператор goto , соответствующая метка перехода должна быть одна в строке и либо сдвинута на одну позицию табуляции влево от остального кода, либо располагаться в начале строки. В любом случае оператор goto и метка перехода должны иметь хороший комментарий по функциональности и цели использования.
- "Проваливание" через
switch
Когда блок кода имеет несколько меток, каждую из них нужно размещать на отдельной строке. Этот элемент стиля программирования согласуется с правилом установки вертикальных отступов и делает перекомпоновку (если она понадобится) сравнений case простой задачей. Использование предоставляемой языком С возможности "проваливания" в операторе switch должно обязательно комментироваться в целях упрощения последующего сопровождения кода. Каждый, кто испытал на себе неприятности от ошибок при использовании этой возможности, знает, насколько это важно!
switch (expr) {
case ABC:
case DEF:
statement;
break;
case UVW:
statement; /*FALLTHROUGH*/
case XYZ:
statement;
break;
}
|
Хотя последний оператор break и не является необходимым, его использование предотвращает ошибку в случае, когда потребуется добавить еще один case . В случае, если используется вариант default , он должен быть последним и не требует оператора break .
- Константы
Символические константы делают код более простым для чтения. Числовых констант, как правило, следует избегать; лучше использовать #define для задания понятного имени. Сосредоточение всех определений в одном месте (лучше всего - в заголовочном файле) также упрощает администрирование изменений в больших проектах, так как позволяет вносить изменения только в директивах #define . Можно рассматривать использование типа данных "перечисление" в качестве улучшенного способа объявления переменных, которые могут принимать только дискретные значения. Использование перечислений также позволяет компилятору выводить предупреждения при ошибках использования типа перечисления. И, наконец, явно перечисленные цифровые константы требуют меньше объяснений о своем происхождении при комментировании.
Константы необходимо объявлять соответственно их использованию, т. е. необходимо указывать 540.0 для числа с плавающей точкой вместо 540 с прямым объявлением типа float . Есть случаи, в которых константы 0 и 1 могут возникать явно вместо своих объявлений строковыми константами. Например, если цикл for индексирует массив, то код:
for (i = 0; i < arraysub; i++)
|
оправдан, а код:
gate_t *front_gate = opens(gate[i], 7);
if (front_gate == 0)
error("can't open %s\n", gate[i]); |
- нет. Во втором примере front_gate - это указатель; когда значение является указателем, то оно должно сравниваться с NULL , а не с 0 . Даже простые значения типа 1 или 0 часто лучше воспринимаются в качестве TRUE и FALSE (или YES и NO ).
Не нужно использовать переменные с плавающей точкой там, где нужны дискретные значения. Это связано с не совсем корректным представлением чисел с плавающей точкой (можно вспомнить второй пример из раздела scanf выше). Сравнивать числа с плавающей точкой лучше используя <= или >= ; явное сравнение (== или != ) может не обнаружить "достаточного" равенства.
Символьные константы должны быть объявлены как символы, а не как числа. Нетекстовые символы являются более трудными для портирования. Если нетекстовые символы необходимы, в частности, при использовании в строках, они должны быть записаны в виде управляющих последовательностей из трех восьмеричных цифр, а не одной (например, '\007' ). Даже в этом случае такое использование символов является платформозависимым и должно восприниматься таковым.
- Условная компиляция
Условная компиляция полезна в случаях, когда требуется реализовать машинозависимый код, при отладке и для установок значений во время компиляции. Различные варианты управления могут легко привести к непредвиденным ситуациям. При использовании #ifdef для машинозависимого кода необходимо быть уверенным, что если тип машины не определен, то возвращается сообщение об ошибке, а не используется конфигурация по умолчанию. Директива #error предназначена как раз для этих целей. При использовании #ifdef для оптимизации лучше применять по умолчанию неоптимизированный код, чем некомпилируемый или некорректный. Необходимо тестировать неоптимизированный код.
Разное
- Утилиты для компиляции, такие как
make , значительно упрощают задачу переноса приложения из одного окружения в другое. В процессе разработки make перекомпилирует только те модули, которые были изменены со времени последней компиляции.
Необходимо использовать lint как можно чаще. lint - это тестер C-программ, который проверяет исходные файлы на языке C для обнаружения несовместимостей типов, расхождений между объявлениями функций и их вызовами, потенциальных ошибок в программе и тому подобного.
Также необходимо изучить документацию компилятора и выяснить, какие опции сделают его более "разборчивым". Работа компилятора заключается в том, чтобы быть точным, поэтому необходимо дать ему возможность выдать отчет о потенциальных проблемах, используя соответствующие опции компиляции.
- Необходимо стараться минимизировать количество глобальных переменных. Один из выигрышей от этого заключается в уменьшении вероятности конфликтов с системными функциями.
- Многие программы завершаются некорректно, когда не получают ожидаемых входных данных. Все программы должны тестироваться на пустые входные данные. Это также поможет понять, как работает программа.
- Не следует полагать о пользователе или его поведении больше, чем этого требует программа. То, что "никогда не может произойти", иногда происходит. Надежная программа защищена от подобных случаев. Если есть непроверяемое граничное условие, то пользователь обязательно столкнется с ним!
Никогда не нужно делать предположений о размере заданного типа данных, особенно указателей.
При использовании в выражениях переменных типа char в большинстве реализаций компиляторов полагается этот тип данных как беззнаковое целое, но в некоторых - как знаковое. Поэтому разумнее каждый раз приводить этот тип данных к требуемому при использовании в арифметических выражениях.
Не нужно полагаться на автоматическую инициализацию переменных и памяти, возвращаемой функцией malloc .
- Следует делать понятной структуру программы и ее цели.
- Необходимо всегда помнить, что разработчику в будущем может потребоваться модифицировать код или перенести его на другую платформу. Поэтому лучше сразу создавать код, который может быть легко портирован.
Заключение
Общеизвестно, что сопровождение приложения отнимает значительную часть времени программиста. Частично это происходит из-за использования платформозависимых и нестандартных особенностей, но в большей степени - из-за плохого стиля программирования. В этой статье дается несколько советов, которые помогают сберечь время, требуемое для сопровождения кода. Следование этим советам сделает сопровождение приложений командой разработчиков более простым.
|