Перефразировав Томаса Эдисона, можно утверждать, что программирование на 10 процентов состоит из вдохновения и на 90 процентов из отладки. Все действительно квалифицированные программисты являются хорошими отладчиками. Чтобы научиться предотвращать множество ошибок, целесообразно рассмотреть некоторые довольно распространенные действия, которые могут привести к их появлению.
В большинстве С-программ применяются операторы инкрементирования и декрементирования, а порядок следования этих операторов, как вы помните, имеет большое значение в зависимости от того, предшествуют они или следуют за переменной. Рассмотрим следующий случай:
y = 10; y = 10; x = y++; x = ++y;
Приведенные две последовательности не эквивалентны. Та, что слева, присваивает переменной х значение 10, а затем инкрементирует у. В другой же последовательности (справа) у сначала инкрементируется, и в результате этого становится равным 11, и только затем значение 11 присваивается переменной х. Таким образом, в первом случае х равно 10, а во втором случае х — 11. В общем случае в сложных выражениях префиксная операция инкрементирования (или декрементирования) осуществляется перед вычислением значения операнда, используемого в последующих действиях. Постфиксный инкремент (или декремент) выполняется в сложных выражениях после того, как значение операнда вычислено. Если забыть об этих правилах, проблем не миновать.
Путь, обычно ведущий к возникновению ошибки очередности вычисления, заключается в изменении имеющейся последовательности операторов. Например, при оптимизации фрагмента кода вы могли бы изменить следующую последовательность:
/* первоначальный код */ x = a + b; a = a + 1;
и представить ее в таком виде:
/* "усовершенствованный" код - ошибка! */ x = ++a + b;
Проблема заключается в том, что эти два фрагмента кода не дают одинаковый результат. Причина состоит в том, что второй способ инкрементирует переменную а до того, как она суммируется с b. А такое действие в первоначальном варианте не было предусмотрено!
Подобные ошибки относятся к разряду трудно обнаруживаемых. Могут быть ключи-подсказки, например циклы, выполняющиеся неправильно, или процедуры, которые не работают из-за таких ошибок. Если у вас возникает сомнение в правильности оператора, перекодируйте его таким образом, чтобы быть уверенным в нем на все 100 процентов.
Очень распространенной ошибкой в С-программах является неправильное применение указателей. Проблемы с указателями условно можно разделить на две основные категории: неправильное представление об использовании косвенной адресации и об операциях над указателями вообще, а также случайное (точнее непредумышленное) применение недействительных или неинициализированных указателей. Решить первую проблему несложно: просто разберитесь окончательно и до конца в том, что означают операторы * и &! Справиться со второй категорией проблем с указателями несколько сложнее.
Ниже приведена программа, иллюстрирующая оба типа ошибок, связанных с указателями:
/* Эта программа содержит ошибку. */ #include <stdlib.h> #include <stdio.h> int main(void) { char *p; *p = (char *) malloc(100); /* эта строка содержит ошибку */ gets(p); printf(p); return 0; }
При запуске такой программы скорее всего произойдет ее сбой. Объясняется это тем, что значение адреса, возвращаемого функцией malloc(), не было присвоено указателю р, а было размещено в ячейку памяти, на которую указывает р, адрес которой в данном случае неизвестен (и в общем случае, непредсказуем). Данный тип ошибки представляет пример фундаментального непонимания выполнения операторов над указателями (а именно выполнения операции *). Как правило, такая ошибка в программах на С допускается начинающими программистами, но иногда эта нелепая оплошность встречается и у опытных профессионалов! Чтобы исправить эту программу, необходимо заменить строку с ошибкой следующей корректной строкой:
*р = (char *) malloc(100); /* эта строка правильная */
Кроме того, данная программа содержит еще одну, причем более коварную ошибку. В ней отсутствует динамическая проверка значения адреса, возвращаемого функцией malloc(). Помните, если память будет исчерпана, malloc() возвратит значение NULL, а тогда указатель использовать нельзя. Использование NULL в качестве указателя объекта недопустимо и практически всегда ведет к аварийному завершению программы. Вот исправленный вариант данной программы, в который включена проверка допустимости указателя:
/* Теперь эта программа написана корректно. */ #include <stdio.h> #include <stdlib.h> int main(void) { char *p; p = (char *) malloc(100); /* эта строка не содержит ошибок */ if(!p) { printf("Нет памяти.\n"); exit(1); } gets(p); printf(p); return 0; }
Следующая часто встречающаяся ошибка заключается в том, что программист забывает инициализировать указатель перед использованием. Обратимся к следующему фрагменту программы:
int *x; *x = 100;
Выполнение такого кода обязательно приведет к проблемам, поскольку указатель х не был инициализирован, а значит, едва ли можно ожидать, что он указывает туда, куда нужно. Фактически вы не знаете, куда указывает х. Присвоение какого-либо значения этой неизвестной области памяти может разрушить что-то, имеющее огромное значение, например другой фрагмент программы или данные.
Самая большая неприятность с "дикими" (т.е. непредсказуемыми) указателями состоит в том, что их невероятно тяжело обнаружить. Если вы присваиваете значение посредством указателя, который не содержит действительный адрес, ваша программа в одних случаях может функционировать вполне корректно, а в других — завершаться аварийным отказом. Чем меньше размер программы, тем выше вероятность, что она будет работать правильно, даже с "блуждающим" указателем. Это объясняется тем, что в таком случае программой используется очень маленький объем памяти, поэтому довольно велики шансы того, что указатель-нарушитель указывает на неиспользуемую область памяти. Но по мере увеличения объема программы подобные сбои будут происходить все чаще и чаще. Но вы скорее попытаетесь объяснить их последними внесенными в программу дополнениями или изменениями, и вряд ли свяжете с ошибками в использовании указателей. Следовательно, вы будете искать ошибки совершено не в том месте.
Подсказкой для распознавания проблем с указателем является то, что такие ошибки часто проявляются нерегулярно и зачастую странными образом. Один раз программа работает вполне корректно, а другой раз — неправильно. Иногда некоторые переменные содержат "мусор", хотя на то нет каких бы то ни было видимых причин. Когда возникают подобные проблемы, проверьте все указатели. Собственно говоря, вы всегда должны проверять все указатели, как только начнут проявляться любые ошибки[1].
Возможно, утешением, станет то, что хотя указатели могут доставить множество хлопот, тем не менее, они являются одним из наиболее мощных средств языка С и стоят преодоления любой проблемы, которую они могут вам преподнести. Просто постарайтесь с самого начала изучить их правильное применение.
Время от времени вы будете сталкиваться с синтаксическими ошибками, сообщения о которых покажутся вам абсурдными и бессмысленными. То ли сообщение об ошибке зашифровано, то ли ошибка, описание которой приводится в сообщении, вообще не похожа на ошибку. Тем не менее, в большинстве случаев в вопросах обнаружения ошибок компилятор оказывается прав. Просто в подобных случаях сам текст сообщения об ошибке чуть-чуть не дотягивает до совершенства. При поиске причин необычных синтаксических ошибок, как правило, необходимо при чтении программы немного возвратиться назад. Поэтому, если вы столкнулись с сообщением об ошибке, которое, судя по всему, не имеет смысла, попробуйте поискать синтаксическую ошибку одной двумя строками выше по тексту вашей программы.
С одной из особенно сногсшибательных ошибок можно познакомиться ближе, если вы попытаетесь скомпилировать следующий код:
char *myfunc(void); int main(void) { /* ... */ } int myfunc(void) /* сообщение об ошибке указывает сюда */ { /* ... */ }
Ваш компилятор выдаст сообщение об ошибке вместе с таким вот разъяснением:
Type mismatch in redeclaration of myfunc(void) (Несоответствие типов при повторном объявлении myfunc(void))
Это сообщение относится к строке листинга программы, которая помечена комментарием о наличии ошибки. Как такое возможно? Ведь в этой строке нет двух функций myfunc(). А разгадка состоит в том, что прототип в верхней строке программы показывает, что myfunc() возвращает значение типа указатель на символ. Это ведет к тому, что в таблице идентификаторов компилятор заполняет строку, содержащую эту информацию. Когда затем в программе компилятор встречает функцию myfunc(), то теперь тип результата указывается как int. Следовательно, вы "повторно объявили", другими словами "переопределили" функцию.
Другая синтаксическая ошибка, которую трудно сразу правильно истолковать, генерируется при попытке скомпилировать следующий код:
/* В тексте данной программы имеется синтаксическая ошибка. */ #include <stdio.h> void func1(void); int main(void) { func1(); return 0; } void func1(void); { printf("Это в func1.\n"); }
Здесь ошибка состоит в наличии точки с запятой после определения функции func1(). Компилятор будет рассматривать это как выражение, находящееся за пределами какой бы то ни было функции, что является ошибкой. Однако различные компиляторы по-разному сообщают об этой ошибке. Некоторые компиляторы выводят в сообщении об ошибке такой текст:
bad declaration syntax (неправильный синтаксис объявления)
,и в то же время указывают на первую открытую скобку после функции func1(). Поскольку вы привыкли в конце выражений ставить точку с запятой, подобную ошибку очень трудно заметить.
Как известно, в С нумерация индексов любого массива начинаются с нуля. Тем не менее, даже опытные профессионалы в пылу творческого вдохновения, бывало, забывали это общеизвестное правило! Рассмотрим следующую программу, которая, как предполагается, должна инициализировать массив из ста целых чисел:
/* Эта программа работать не будет. */ int main(void) { int x, num[100]; for(x=1; x <= 100; ++x) num[x] = x; return 0; }
Цикл for в этой программе выполнен неправильно по двум причинам. Во-первых, он не инициализирует num[0], первый элемент массива num. Во-вторых, он пытается проинициализировать элемент массива с номером на единицу больше, чем у последнего элемента массива, поскольку num[99] как раз и является последним элементом массива, а параметр цикла достигает значения 100. Правильно было бы записать эту программу следующим образом:
/* Здесь все правильно. */ int main(void) { int x, num[100]; for(x=0; x < 100; ++x) num[x] = x; return 0; }
Помните, в массиве из 100 элементов элементы пронумерованы числами от 0 до 99.
И в среде прогона программ, написанных на языке С, и во многих стандартных библиотечных функциях почти не имеется (а иногда они вообще отсутствуют) средств динамической проверки принадлежности к диапазону (т.е. средств контроля границ). Например, если в программе произойдет выход за границы массива, то такая ошибка может остаться незамеченной. Рассмотрим следующую программу, которая должна считывать строку символов из буфера клавиатуры и отображать ее на экране монитора:
#include <stdio.h> int main(void) { int var1; char s[10]; int var2; var1 = 10; var2 = 10; gets(s); printf("%s %d %d", s, var1, var2); return 0; }
В этом фрагменте нет очевидных ошибок кодирования. Тем не менее, вызов функции gets() с параметром s может косвенно привести к ошибке. В данной программе переменная s объявлена как массив символов (строка длиной в 10 знаков). Но что произойдет, если пользователь введет больше десяти знаков? Это приведет к выходу за границы массива s, и значение переменной var1 или var2, а возможно, и их обеих будет перезаписано. Следовательно, var1 и (или) var2 не будут содержать правильных значений. Это вызвано тем, что для хранения локальных переменных все С-компиляторы применяют стек. Переменные var1, var2, а также s могут располагаться в памяти так, как показано на рис. 28.1. (Ваш компилятор С может поменять порядок следования переменных var1, var2 и s.)
Предположим, что порядок распределения ячеек памяти совпадает с изображенным на рис. 28.1. Тогда, если произойдет выход за границы массива s, то дополнительные (лишние) символы будут помещены в область, в которой должна находиться переменная var2. Это практически уничтожит информацию, ранее записанную там.
Поэтому на экран будет выведено не число 10 в качестве значения обеих целых переменных, а в качестве значения переменной, поврежденной в результате выхода за границы массива s, будет отображено что-нибудь другое. А вы можете искать ошибку совсем в другом месте.
Младшие . адреса . памяти . |-- +--------------------+ --| var1 -| +--------------------+ |- 2 байта |-- +--------------------+ --| | +--------------------+ | | +--------------------+ | | +--------------------+ | | +--------------------+ | s -| +--------------------+ |- 10 байтов | +--------------------+ | | +--------------------+ | | +--------------------+ | | +--------------------+ | |-- +--------------------+ --| var2 -| +--------------------+ |- 2 байта |-- +--------------------+ --| Старшие . адреса . памяти . |
В рассмотренной программе потенциальная ошибка из-за выхода за границы может быть исключена за счет применения функции fgets() вместо gets(). Функция fgets() предоставляет возможность устанавливать максимальное количество считываемых символов. Единственная проблема состоит в том, что fgets() считывает и сохраняет еще и символ разделителя строк, поэтому в большинстве приложений его необходимо будет удалять.
В современной среде программирования отсутствие даже одного прототипа функции является непростительным упущением, "отступничеством" от мудрых принципов и здравого смысла. Чтобы понять почему именно, рассмотрим следующую программу, которая выполняет умножение двух чисел с плавающей запятой:
/* Эта программа содержит ошибку. */ #include <stdio.h> int main(void) { float x, y; scanf("%f%f", &x, &y); printf("%f", mul(x, y)); return 0; } double mul(float a, float b) { return a*b; }
В данном случае, поскольку прототип функции mul() отсутствует, при компиляции функции main() предполагается, что в результате выполнения mul() будет возвращена целочисленная величина. Но в действительности mul() возвращает число с плавающей запятой. Допустим, что для целого числа выделяется 4 байта, а для чисел двойной точности (double) — 8 байтов. Это значит, что в действительности только четыре байта из восьми, необходимых для двойной точности, будут использованы в операторе printf() внутри функции main(). Это приведет к неправильному ответу, выводимому на экран дисплея.
Чтобы исправить эту программу, достаточно создать прототип функции mul(). Корректный вариант программы будет выглядеть следующим образом:
/* Это правильная программа. */ #include <stdio.h> double mul(float a, float b); int main(void) { float x, y; scanf("%f%f", &x, &y); printf("%f", mul(x, y)); return 0; } double mul(float a, float b) { return a*b; }
В данном случае прототип указывает, что при компиляции функции main() необходимо учитывать, что функция mul() возвращает значение с удвоенной точностью.
Тип любого формального параметра, должен соответствовать типу фактического параметра. Хотя благодаря прототипам функций компиляторы могут обнаруживать многие несоответствия типов аргументов (параметров), они не могут обнаружить все. Более того, когда функция имеет переменное количество параметров, компилятор не может обнаружить несоответствие их типов. Например, рассмотрим функцию scanf(), которая принимает большое количество разнообразных аргументов. Не забывайте, что scanf() ожидает принять адреса своих аргументов, а не их значения. И никакая сила не сделает за вас правильную подстановку. Например, следующая последовательность операторов
int x; scanf("%d", x);
содержит ошибку, поскольку передается значение переменной х, а не ее адрес. Тем не менее, вызов этой функции scanf() будет скомпилирован без сообщения об ошибке, и лишь во время выполнения этого оператора выявится ошибка. Правильный вариант вызова функции scanf() приведен ниже:
scanf("%d", &x);
Все компиляторы С используют стек для хранения локальных переменных, адресов возврата и передаваемых функциям параметров. Однако стек не безграничен, и, в конце концов, может быть исчерпан. Тогда попытка записи очередного элемента в него приведет к переполнению стека. Когда такое происходит, программа или полностью "умирает", или продолжает выполняться в ненормальном причудливом стиле. Самое неприятное в переполнении стека заключается в том, что оно в большинстве случаев происходит безо всякого предупреждения и оказывает на программу столь серьезное воздействие, что определить, что именно было сделано неправильно, иногда бывает невероятно трудно. Единственной приемлемой подсказкой может служить то, что в некоторых случаях переполнение стека вызвано выходом из-под контроля рекурсивных функций. Если в вашей программе используются рекурсивные функции и вы столкнулись с необъяснимыми сбоями в ее работе, проверьте условия завершения в рекурсивных функциях.
И еще одно замечание. Некоторые компиляторы позволяют увеличить объем памяти, резервируемой под стек. Если ваша программа во всем остальном не имеет ошибок, но быстро исчерпывает стековое пространство (возможно из-за глубокой степени вложенности или рекурсивности функций), необходимо просто увеличить размер стека.
Многие компиляторы поставляются вместе с отладчиком, который представляет собой программу, помогающую отладить разрабатываемый код. В общем случае отладчики позволяют шаг за шагом исполнять код разрабатываемой программы, устанавливать точки останова и контролировать содержимое различных переменных. Современные отладчики, например такие, как поставляемые в составе пакета Visual C++, являются действительно замечательными инструментальными средствами, которые могут оказать существенную помощь в обнаружении ошибок в разрабатываемом коде. Хороший отладчик стоит дополнительного времени и усилий, которые необходимы на его изучение, чтобы в дальнейшем эффективно его применять. Как бы то ни было, хороший программист никогда не откажется от работы с отладчиком для реализации надежного проекта и выполнения тонких работ.
Каждый разработчик имеет свой собственный подход в программировании и отладке. Тем не менее, длительный опыт показывает, что существуют технические приемы, которые значительно лучше, чем остальные. В отношении отладки считается, что наиболее эффективным методом в плане времени и стоимости является инкрементное (нарастающее) тестирование, даже если может показаться, что этот подход может замедлить на первых порах процесс разработки. Инкрементное тестирование является технологическим приемом, гарантирующим, что вы всегда будете иметь работоспособную программу. В чем же его суть? Уже на самых ранних стадиях процесса разработки функциональный блок. Функциональный блок — это просто фрагмент работающего кода. По мере добавления нового кода к этому блоку, он тестируется и отлаживается. Таким способом программист может обнаруживать ошибки без особого труда, поскольку, вероятнее всего, ошибки будут присутствовать в более новом коде (добавке) или возникать из-за плохого взаимодействия с функциональным блоком.
Время отладки прямо пропорционально общему количеству строк кода, в котором программист ищет ошибки. Благодаря инкрементному тестированию количество строк кода, в котором необходимо искать ошибки, ограничено как правило, количеством вновь добавленных строк. Другими словами, ошибка, скорее всего, содержится в строках, которые не входят в состав функционального блока. Эта ситуация проиллюстрирована на рис. 28.2. Любому программисту хочется минимизировать объем отлаживаемого фрагмента программы. Метод инкрементного тестирования позволяет не тестировать те участки, где эта работа уже была проведена. Таким образом, можно уменьшить область, в которой вероятнее всего прячется ошибка.
Вероятнее всего, ошибка,если она есть, находиться здесь | +--------|---------+ | | | | +------V-------+ | | |Функциональный| | | | блок | | | +--------------+ | | | | Добавленный код | +------------------+ Рис. 28.2. При регулярном использовании метода инкрементального тести рования ошибки, если они есть, вероятнее всего находятся в до бавленном коде
Крупные проекты часто можно разбить на несколько модулей, слабо взаимодействующих между собой. В таких случаях можно выделить несколько функциональных блоков, что позволит вести параллельную разработку проекта.
Инкрементное тестирование — просто технологический прием, благодаря которому который всегда можно иметь работоспособный код. Поэтому всякий раз, когда появляется возможность выполнить кусочек разрабатываемой программы, вы должны запустить его на выполнение и тщательно протестировать его. По мере добавления к программе новых фрагментов продолжайте тестировать их, а также их интерфейс с уже проверенным функциональным кодом. Этот способ позволяет разрабатывать программу так, что большинство ошибок будет сконцентрировано в небольшой области кода. Конечно, вы никогда не должны упускать из виду то, что ошибка могла быть пропущена и в функциональном блоке, но все же данный метод тестирования уменьшает вероятность такого случая.
[1]Вот еше несколько универсальных советов, как избавиться от проблем: "Всегда все тщательно проверяйте!", "Никогда не делайте ошибок!", "Всегда все кодируйте правильно!" и т.д. Когда я слышу подобные советы, я вспоминаю, как всем известная Алиса реагировала на подобные высказывания: "Но ведь я не могла!", на что Шалтай-Болтай ей отвечал: "Я и не говорю, что ты могла; я говорю, что ты должна была!" Честно говоря, позиция Алисы мне очень и очень близка, ведь недаром же известный философ Хинтикка доказал, что аморально требовать от человека то, чего он не может выполнить. Если теперь предположить (вопреки тому, что заказчики считают, что программисты обязаны быть роботами), что все программисты — люди, то мы можем придти к заключению, что не во всех программах всегда проверяется применение всех указателей. (С точки зрения заказчиков это, конечно, недопустимо.) А потому совет, даваемый автором, хотя и смахивает на один из универсальных, вполне заслуживает того, чтобы прислушаться к нему. Статистика же подтверждает, что это — самый лучший совет из всех, которые можно дать при отладке программ с указателями! Так что ничего не поделаешь — не ленитесь!