Язык программирования Си Брайан Керниган, Деннис Ритчи 3е издание Версия 1 Table of Contents
Скачать 2.33 Mb.
|
4. Функции и структура программы Функции разбивают большие вычислительные задачи на более мелкие и позволяют воспользоваться тем, что уже сделано другими разработчиками, а не начинать создание программы каждый раз "с нуля". В выбранных должным образом функциях "упрятаны" несущественные для других частей программы детали их функционирования, что делает программу в целом более ясной и облегчает внесение в нее изменений. Язык проектировался так, чтобы функции были эффективными и простыми в использовании. Обычно программы на Си состоят из большого числа небольших функций, а не из немногих больших. Программу можно располагать в одном или нескольких исходных файлах. Эти файлы можно компилировать отдельно, а загружать вместе, в том числе и с ранее откомпилированными библиотечными функциями. Процесс загрузки здесь не рассматривается, поскольку он различен в разных системах. Объявление и определение функции — это та область, где стандартом ANSI в язык внесены самые существенные изменения. Как мы видели в главе 1, в описании функции теперь разрешено задавать типы аргументов. Синтаксис определения функции также изменен, так что теперь объявления и определения функций соответствуют друг другу. Это позволяет компилятору обнаруживать намного больше ошибок, чем раньше. Кроме того, если типы аргументов соответствующим образом объявлены, то необходимые преобразования аргументов выполняются автоматически. Стандарт вносит ясность в правила, определяющие области видимости имен; в частности, он требует, чтобы для каждого внешнего объекта было только одно определение. В нем обобщены средства инициализации: теперь можно инициализировать автоматические массивы и структуры. Улучшен также препроцессор Си. Он включает более широкий набор директив условной компиляции, предоставляет возможность из макроаргументов генерировать строки в кавычках, а кроме того, содержит более совершенный механизм управления процессом макрорасширения. 4.1. Основные сведения о функциях Начнем с того, что сконструируем программу, печатающую те строки вводимого текста, в которых содержится некоторый "образец", заданный в виде строки символов. (Эта программа представляет собой частный случай функции grep системы UNIX.) Рассмотрим пример: в результате поиска образца "ould" в строках текста Ah Love! could you and I with Fate conspire To grasp this sorry Scheme of Things entire, Would not we shatter it to bits — and then Re-mould it nearer to the Heart's Desire! мы получим Ah Love! could you and I with Fate conspire Would not we shatter it to bits — and then Re-mould it nearer to the Heart's Desire! Работа по поиску образца четко распадается на три этапа: while (существует еще строка) if (строка содержит образец) напечатать ее Хотя все три составляющие процесса поиска можно поместить в функцию main , все же лучше сохранить приведенную структуру и каждую ее часть реализовать в виде отдельной функции. Легче иметь дело с тремя небольшими частями, чем с одной большой, поскольку, если несущественные особенности реализации скрыты в функциях, вероятность их нежелательного воздействия друг на друга минимальна. Кроме того, оформленные в виде функций соответствующие части могут оказаться полезными и в других программах. Конструкция " while (существует еще строка) " реализована в getline (см. главу 1), а фразу "напечатать ее" можно записать с помощью готовой функции printf . Таким образом, нам остается перевести на Си только то, что определяет, входит ли заданный образец в строку. Чтобы решить эту задачу, мы напишем функцию strindex(s, t) , которая указывает место (индекс) в строке s , где начинается строка t , или -1, если s не содержит t . Так как в Си нумерация элементов в массивах начинается с нуля, отрицательное число -1 подходит в качестве признака неудачного поиска. Если далее нам потребуется более сложное отождествление по образцу, мы просто заменим strindex на другую функцию, оставив при этом остальную часть программы без изменений. (Библиотечная функция strstr аналогична функции strindex и отличается от последней только тем, что возвращает не индекс, а указатель.) После такого проектирования программы ее "деталировка" оказывается очевидной. Мы имеем представление о программе в целом и знаем, как взаимодействуют ее части. В нашей программе образец для поиска задается строкой-литералом, что снижает ее универсальность. В главе 5 мы еще вернемся к проблеме инициализации символьных массивов и покажем, как образец сделать параметром, устанавливаемым при запуске программы. Здесь приведена несколько измененная версия функции getline , и было бы поучительно сравнить ее с версией, рассмотренной в главе 1. #include #define MAXLINE 1000 /* максимальный размер вводимой строки */ int getline(char line[], int max); int strindex(char source[], char searchfor[]); char pattern[] = "ould"; /* образец для поиска */ /* найти все строки, содержащие образец */ main() { char line[MAXLINE]; int found = 0; while (getlinedine, MAXLINE) > 0) if (strindex(line, pattern) >= 0) { printf ("%s", line); found++; } return found; } /* getline: читает строку в s, возвращает длину */ int getline(char s[], int lim) { int c, i; i = 0; while (--lim > 0 && (c = getchar()) != EOF && с != '\n' ) s[i++] = с; if (с == '\n') s[i++] = c; s[i] = '\0'; return i; } /* strindex: вычисляет место t в s или выдает -1, если t нет в s */ int strindex (char s[], char t[]) { int i, j, k; for (i = 0; s[i] != '\0'; i++) { for (j = i, k = 0; t[k] != '\0' && s[j] == t[k]; j++, k++) ; if (k > 0 && t[k] == '\0') return i; } return -1; } Определение любой функции имеет следующий вид: тип-результата имя-функции (объявления аргументов) { объявления и инструкции } Отдельные части определения могут отсутствовать, как, например, в определении "минимальной" функции dummy() {} которая ничего не вычисляет и ничего не возвращает. Такая ничего не делающая функция в процессе разработки программы бывает полезна в качестве "хранителя места". Если тип результата опущен, то предполагается, что функция возвращает значение типа int Любая программа — это просто совокупность определений переменных и функций. Связи между функциями осуществляются через аргументы, возвращаемые значения и внешние переменные. В исходном файле функции могут располагаться в любом порядке; исходную программу можно разбивать на любое число файлов, но так, чтобы ни одна из функций не оказалась разрезанной. Инструкция return реализует механизм возврата результата от вызываемой функции к вызывающей. За словом return может следовать любое выражение: return выражение; Если потребуется, выражение будет приведено к возвращаемому типу функции. Часто выражение заключают в скобки, но они не обязательны. Вызывающая функция вправе проигнорировать возвращаемое значение. Более того, выражение в return может отсутствовать, и тогда вообще никакое значение не будет возвращено в вызывающую функцию. Управление возвращается в вызывающую функцию без результирующего значения также и в том случае, когда вычисления достигли "конца" (т. е. последней закрывающей фигурной скобки функции). Не запрещена (но должна вызывать настороженность) ситуация, когда в одной и той же функции одни return имеют при себе выражения, а другие — не имеют. Во всех случаях, когда функция "забыла" передать результат в return , она обязательно выдаст "мусор". Функция main в программе поиска по образцу возвращает в качестве результата количество найденных строк. Это число доступно той среде, из которой данная программа была вызвана. Механизмы компиляции и загрузки Си-программ, расположенных в нескольких исходных файлах, в разных системах могут различаться. В системе UNIX, например, эти работы выполняет упомянутая в главе 1 команда cc . Предположим, что три функции нашего последнего примера расположены в трех разных файлах: main.с , getline.с и strindex.с . Тогда команда cc main.с getline.c strindex.c скомпилирует указанные файлы, поместив результат компиляции в файлы объектных модулей main.о , getline.о и strindex.o , и затем загрузит их в исполняемый файл a.out . Если обнаружилась ошибка, например, в файле main.с , то его можно скомпилировать снова и результат загрузить ранее полученными объектными файлами, выполнив следующую команду: cc main.с getline.o strindex.о Команда cc использует стандартные расширения файлов " .с " и" .о ", чтобы отличать исходные файлы от объектных. Упражнение 4.1. Напишите функцию strindex(s, t) , которая выдает позицию самого правого вхождения t в s или -1, если вхождения не обнаружено. 4.2. Функции, возвращающие нецелые значения В предыдущих примерах функции либо вообще не возвращали результирующих значений ( void ), либо возвращали значения типа int . А как быть, когда результат функции должен иметь другой тип? Многие вычислительные функции, как, например, sqrt , sin и cos , возвращают значения типа double ; другие специальные функции могут выдавать значения еще каких-то типов. Чтобы проиллюстрировать, каким образом функция может возвратить нецелое значение, напишем функцию atof(s) , которая переводит строку s в соответствующее число с плавающей точкой двойной точности. Функция atof представляет собой расширение функции atoi , две версии которой были рассмотрены в главах 2 и 3. Она имеет дело со знаком (которого может и не быть), с десятичной точкой, а также с целой и дробной частями, одна из которых может отсутствовать. Наша версия не является высококачественной программой преобразования вводимых чисел; такая программа потребовала бы заметно больше памяти. Функция atof входит в стандартную библиотеку программ; ее описание содержится в заголовочном файле Прежде всего отметим, что объявлять тип возвращаемого значения должна сама atof , так как этот тип не есть int . Указатель типа задается перед именем функции. #include /* atof: преобразование строки s в double */ double atof (char s[]) { double val, power; int i, sign; for (i = 0; isspace (s[i]); i++) ; /* игнорирование левых символов-разделителей */ sign = (s[i] == '-') ? -1 : 1; if (s[i] == '+' || s[i] == '-') i++; for (val = 0.0; isdigit (s[i]); i++) val = 10.0 * val + (s[i] - '0'); if (s[i] =='.') i++; for (power = 1.0; isdigit(s[i]); i++) { val = 10.0 * val + (s[i] - '0'); power *= 10.0; } return sign * val / power; } Кроме того, важно, чтобы вызывающая программа знала, что atof возвращает нецелое значение. Один из способов обеспечить это — явно описать atof в вызывающей программе. Подобное описание демонстрируется ниже в программе простенького калькулятора (достаточного для проверки баланса чековой книжки), который каждую вводимую строку воспринимает как число, прибавляет его к текущей сумме и печатает ее новое значение. #include #define MAXLINE 100 /* примитивный калькулятор */ main() { double sum, atof (char[]); char line[MAXLINE]; int getline (char line[], int max); sum = 0; while (getline(line, MAXLINE) > 0) printf ("\t%g\n", sum += atof(line)); return 0; } В объявлении double sum, atof (char[]); говорится, что sum — переменная типа double , a atof — функция, которая принимает один аргумент типа char[] и возвращает результат типа double Объявление и определение функции atof должны соответствовать друг другу. Если в одном исходном файле сама функция atof и обращение к ней в main имеют разные типы, то это несоответствие будет зафиксировано компилятором как ошибка. Но если функция atof была скомпилирована отдельно (что более вероятно), то несоответствие типов не будет обнаружено, и atof возвратит значение типа double , которое функция main воспримет как int , что приведет к бессмысленному результату. Это последнее утверждение, вероятно, вызовет у вас удивление, поскольку ранее говорилось о необходимости соответствия объявлений и определений. Причина несоответствия, возможно, будет следствием того, что вообще отсутствует прототип функции, и функция неявно объявляется при первом своем появлении в выражении, как, например, в sum += atof(line) Если в выражении встретилось имя, нигде ранее не объявленное, за которым следует открывающая скобка, то такое имя по контексту считается именем функции, возвращающей результат типа int ; при этом относительно ее аргументов ничего не предполагается. Если в объявлении функции аргументы не указаны, как в double atof(); то и в этом случае считается, что ничего об аргументах atof не известно, и все проверки на соответствие ее параметров будут выключены. Предполагается, что такая специальная интерпретация пустого списка позволит новым компиляторам транслировать старые Си-программы. Но в новых программах пользоваться этим — не очень хорошая идея. Если у функции есть аргументы, опишите их, если их нет, используйте слово void Располагая соответствующим образом описанной функцией atof , мы можем написать функцию atoi , преобразующую строку символов в целое значение, следующим образом: /* atoi: преобразование строки s в int с помощью atof */ int atoi (char s[]) { double atof (char s[]); return (int) atof (s); } Обратите внимание на вид объявления и инструкции return . Значение выражения в return выражение; перед тем, как оно будет возвращено в качестве результата, приводится к типу функции. Следовательно, поскольку функция atoi возвращает значение int , результат вычисления atof типа double в инструкции return автоматически преобразуется в тип int . При преобразовании возможна потеря информации, и некоторые компиляторы предупреждают об этом. Оператор приведения явно указывает на необходимость преобразования типа и подавляет любое предупреждающее сообщение. Упражнение 4.2. Дополните функцию atof таким образом, чтобы она справлялась с числами вида 123.456e-6 в которых после мантиссы может стоять е (или Е) с последующим порядком (быть может, со знаком). 4.3. Внешние переменные Программа на Си обычно оперирует с множеством внешних объектов: переменных и функций. Прилагательное "внешний" (external) противоположно прилагательному "внутренний", которое относится к аргументам и переменным, определяемым внутри функций. Внешние переменные определяются вне функций и потенциально доступны для многих функций. Сами функции всегда являются внешними объектами, поскольку в Си запрещено определять функции внутри других функций. По умолчанию одинаковые внешние имена, используемые в разных файлах, относятся к одному и тому же внешнему объекту (функции). (В стандарте это называется редактированием внешних связей (external linkage 7 ).) В этом смысле внешние переменные похожи на области COMMON в фортране и на переменные самого внешнего блока в Паскале. Позже мы покажем, как внешние функции и переменные сделать видимыми только внутри одного исходного файла. Поскольку внешние переменные доступны всюду, их можно использовать в качестве связующих данных между функциями как альтернативу связей через аргументы и возвращаемые значения. Для любой функции внешняя переменная доступна по ее имени, если это имя было должным образом объявлено. 7 Сейчас уже и в русский язык прочно вошло слово "линкование". — Примеч. ред. Если число переменных, совместно используемых функциями, велико, связи между последними через внешние переменные могут оказаться более удобными и эффективными, чем длинные списки аргументов. Но, как отмечалось в главе 1, к этому заявлению следует относиться критически, поскольку такая практика ухудшает структуру программы и приводит к слишком большому числу связей между функциями по данным. Внешние переменные полезны, так как они имеют большую область действия и время жизни. Автоматические переменные существуют только внутри функции, они возникают в момент входа в функцию и исчезают при выходе из нее. Внешние переменные, напротив, существуют постоянно, так что их значения сохраняются и между обращениями к функциям. Таким образом, если двум функциям приходится пользоваться одними и теми же данными и ни одна из них не вызывает другую, то часто бывает удобно оформить эти общие данные в виде внешних переменных, а не передавать их в функцию и обратно через аргументы. В связи с приведенными рассуждениями разберем пример. Поставим себе задачу написать программу- калькулятор, понимающую операторы + , - , * и / . Такой калькулятор легче будет написать, если ориентироваться на польскую, а не инфиксную запись выражений. (Обратная польская запись применяется в некоторых карманных калькуляторах и в таких языках, как Forth и Postscript.) В обратной польской записи каждый оператор следует за своими операндами. Выражение в инфиксной записи, скажем (1 - 2) * (4 + 5) в польской записи представляется как 1 2 - 4 5 + * Скобки не нужны, неоднозначности в вычислениях не бывает, поскольку известно, сколько операндов требуется для каждого оператора. Реализовать нашу программу весьма просто. Каждый операнд посылается в стек; если встречается оператор, то из стека берется соответствующее число операндов (в случае бинарных операторов два) и выполняется операция, после чего результат посылается в стек. В нашем примере числа 1 и 2 посылаются в стек, затем замещаются на их разность -1. Далее в стек посылаются числа 4 и 5, которые затем заменяются их суммой (9). Числа -1 и 9 заменяются в стеке их произведением (т. е. -9). Встретив символ новой строки, программа извлекает значение из стека и печатает его. Таким образом, программа состоит из цикла, обрабатывающего на каждом своем шаге очередной встречаемый оператор или операнд: while (следующий элемент не конец-файла) if (число) послать его в стек else if (оператор) взять из стека операнды выполнить операцию результат послать в стек else if (новая-строка) взять с вершины стека число и напечатать else ошибка Операции "послать в стек" и "взять из стека" сами по себе тривиальны, однако по мере добавления к ним механизмов обнаружения и нейтрализации ошибок становятся достаточно длинными. Поэтому их лучше оформить в виде отдельных функций, чем повторять соответствующий код по всей программе. И конечно необходимо иметь отдельную функцию для получения очередного оператора или операнда. Главный вопрос, который мы еще не рассмотрели, — это вопрос о том, где расположить стек и каким функциям разрешить к нему прямой доступ. Стек можно расположить в функции main и передавать сам стек и текущую позицию в нем в качестве аргументов функциям push ("послать в стек") и pop ("взять из стека"). Но функции main нет дела до переменных, относящихся к стеку, — ей нужны только операции по помещению чисел в стек и извлечению их оттуда. Поэтому мы решили стек и связанную с ним информацию хранить во внешних переменных, доступных для функций push и pop , но не доступных для main Переход от эскиза к программе достаточно легок. Если теперь программу представить как текст, расположенный в одном исходном файле, она будет иметь следующий вид: #include /* могут быть в любом количестве */ #define /* могут быть в любом количестве */ объявления функций для main main () {...} внешние переменные для push и pop void push (double f) {...} double pop (void) {...} int getop(char s[] ) {...} подпрограммы, вызываемые функцией getop Позже мы обсудим, как текст этой программы можно разбить на два или большее число файлов. Функция main — это цикл, содержащий большой переключатель switch , передающий управление на ту или иную ветвь в зависимости от типа оператора или операнда. Здесь представлен более типичный случай применения переключателя switch по сравнению с рассмотренным в параграфе 3.4. #include #include #define MAXOP 100 /* макс. размер операнда или оператора */ #define NUMBER '0' /* признак числа */ int getop (char []); void push (double); double pop (void); /* калькулятор с обратной польской записью */ main () { int type; double op2; char s[MAXOP]; while ((type = getop (s)) != EOF) { switch (type) { case NUMBER: push (atof (s)); break; case '+': push (pop() + pop()); break; case '*': push (pop() * pop()); break; case '-': op2 = pop(); push (pop() - op2); break; case '/' : op2 = pop(); if (op2 != 0.0) push (pop() / op2); else printf("ошибка: деление на нуль\п"); break; case '\n' : printf("\t%.8g\n", pop()); break; default: printf("ошибка: неизвестная операция %s\n", s); break; } } return 0; } Так как операторы + и * коммутативны, порядок, в котором операнды берутся из стека, не важен, однако в случае операторов - и / , левый и правый операнды должны различаться. Так, в push(pop() - рор()); /* НЕПРАВИЛЬНО */ очередность обращения к pop не определена. Чтобы гарантировать правильную очередность, необходимо первое значение из стека присвоить временной переменной, как это и сделано в main #define MAXVAL 100 /* максимальная глубина стека */ int sp = 0; /* следующая свободная позиция в стеке */ double val[ MAXVAL ]; /* стек */ /* push: положить значение f в стек */ void push(double f) { if (sp < MAXVAL) val[sp++] = f; else printf( "ошибка: стек полон, %g не помещается\п", f); } /* pop: взять с вершины стека и выдать в качестве результата */ double pop(void) { if (sp > 0) return val[--sp]; else { printf( "ошибка: стек пуст\п"); return 0.0; } } Переменная считается внешней, если она определена вне функции. Таким образом, стек и индекс стека, которые должны быть доступны и для push , и для pop , определяются вне этих функций. Но main не использует ни стек, ни позицию в стеке, и поэтому их представление может быть скрыто от main Займемся реализацией getop — функции, получающей следующий оператор или операнд. Нам предстоит решить довольно простую задачу. Более точно: требуется пропустить пробелы и табуляции; если следующий символ — не цифра и не десятичная точка, то нужно выдать его; в противном случае надо накопить строку цифр с десятичной точкой, если она есть, и выдать число NUMBER в качестве результата. #include /* getop: получает следующий оператор или операнд */ int getop(char s[]) { int i, c; while ((s[0] = с = getch()) == ' ' || с == '\t' ) ; s[1] = '\0'; if (!isdigit(c) && с != '.') return с; /* не число */ i = 0; if (isdigit(c)) /* накапливаем целую часть */ while (isdigit(s[++i] = с = getch())) ; if (с == '.') /* накапливаем дробную часть */ while (isdigit(s[++i] = с = getch())) ; s[i] = '\0'; if (c != EOF) ungetch(c); return NUMBER; } Как работают функции getсh и ungetch ? Во многих случаях программа не может "сообразить", прочла ли она все, что требуется, пока не прочтет лишнего. Так, накопление числа производится до тех пор, пока не встретится символ, отличный от цифры. Но это означает, что программа прочла на один символ больше, чем нужно, и последний символ нельзя включать в число. Эту проблему можно было бы решить при наличии обратной чтению операции "положить-назад", с помощью которой можно было бы вернуть ненужный символ. Тогда каждый раз, когда программа считает на один символ больше, чем требуется, эта операция возвращала бы его вводу, и остальная часть программы могла бы вести себя так, будто этот символ вовсе и не читался. К счастью, описанный механизм обратной посылки символа легко моделируется с помощью пары согласованных друг с другом функций, из которых getch поставляет очередной символ из ввода, a ungetch отправляет символ назад во входной поток, так что при следующем обращении к getch мы вновь его получим. Нетрудно догадаться, как они работают вместе. Функция ungetch запоминает посылаемый назад символ в некотором буфере, представляющем собой массив символов, доступный для обеих этих функций; getch читает из буфера, если там что-то есть, или обращается к getchar , если буфер пустой. Следует предусмотреть индекс, указывающий на положение текущего символа в буфере. Так как функции getch и ungetch совместно используют буфер и индекс, значения последних должны между вызовами сохраняться. Поэтому буфер и индекс должны быть внешними по отношению к этим программам, и мы можем записать getch , ungetch и общие для них переменные в следующем виде: #define BUFSIZE 100 char buf[BUFSIZE]; /* буфер для ungetch */ int bufp = 0; /* след, свободная позиция в буфере */ int getch(void) /* взять (возможно возвращенный) символ */ { return (bufp > 0) ? buf[--bufp] : getchar(); } void ungetch(int с) /* вернуть символ на ввод */ { if (bufp >= BUFSIZE) printf ("ungetch: слишком много символов\n"); else buf[bufp++] = с; } Стандартная библиотека включает функцию ungetc , обеспечивающую возврат одного символа (см. главу 7). Мы же, чтобы проиллюстрировать более общий подход, для запоминания возвращаемых символов использовали массив. |