Итерация свойственна человеку, рекурсия божественна.
- Л. Питер Дойч
Все нетривиальные программы собираются из нескольких раздельно
компилируемых единиц (их принято называть просто файлами). В этой
главе описано, как раздельно откомпилированные функции могут
обращаться друг к другу, как такие функции могут совместно
пользоваться данными (разделять данные), и как можно обеспечить
согласованность типов, которые используются в разных файлах
программы. Функции обсуждаются довольно подробно. Сюда входят
передача параметров, параметры по умолчанию, перегрузка имен
функций, и, конечно же, описание и определение функций. В конце
описываются макросы.
4.1 Введение
Иметь всю программу в одном файле обычно невозможно, поскольку
коды стандартных библиотек и операционной системы находятся где-то
в другом месте. Кроме того, хранить весь текст пользовательской
программы в одном файле как правило непрактично и неудобно. Способ
организации программы в файлы может помочь читающему охватить всю
структуру программы, а также может дать возможность компилятору
реализовать эту структуру. Поскольку единицей компиляции является
файл, то во всех случаях, когда в файл вносится изменение (сколь бы
мало оно ни было), весь файл нужно компилировать заново. Даже для
программы умеренных размеров время, затрачиваемое на
перекомпиляцию, можно значительно снизить с помощью разбиения
программы на файлы подходящих размеров.
Рассмотрим пример с калькулятором. Он был представлен в виде
одного исходного файла. Если вы его набили, то у вас наверняка были
небольшие трудности с расположением описаний в правильном порядке,
и пришлось использовать по меньшей мере одно "фальшивое" описание,
чтобы компилятор смог обработать взаимно рекурсивные функции
expr(), term() и prim(). В тексте уже отмечалось, что программа
состоит из четырех частей (лексического анализатора, программы
синтаксического разбора, таблицы имен и драйвера), но это никак не
было отражено в тексте самой программы. По сути дела, калькулятор
был написан по-другому. Так это не делается; даже если в этой
программе "на выброс" пренебречь всеми соображениями методологии
программирования, эксплуатации и эффективности компиляции, автор
все равно разобьет эту программу в 200 строк на несколько файлов,
чтобы программировать было приятнее.
Программа, состоящая из нескольких раздельно компилируемых
файлов, должна быть согласованной в смысле использования имен и
типов, точно так же, как и программа, состоящая из одного исходного
файла. В принципе, это может обеспечить и компоновщик*1.
Компоновщик, линкер
-
это программа, стыкующая отдельно скомпилированные части вместе.
Компоновщик часто (путая) называют загрузчиком. В UNIX'е
компоновщик называется ld. Однако компоновщики, имеющиеся в
большинстве систем, обеспечивают очень слабую поддержку проверки
согласованности.
Программист может скомпенсировать недостаток поддержки со стороны
компоновщика, предоставив дополнительную информацию о типах
(описания). После этого согласованность программы обеспечивается
проверкой согласованности описаний, которые находятся в отдельно
компилируемых частях. Средства, которые это обеспечивают, в вашей
системе будут. C++ разработан так, чтобы способствовать такой явной
компоновке*2.
4.2 Компоновка (линковка)
Если не указано иное, то имя, не являющееся локальным для функции
или класса, в каждой части программы, компилируемой отдельно,
должно относиться к одному и тому же типу, значению, функции или
объекту. То есть, в программе может быть только один нелокальный
тип, значение, функция или объект с этим именем. Рассмотрим,
например, два файла:
// file1.c:
int a = 1;
int f() { /* что-то делает */ }
// file2.c:
extern int a;
int f();
void g() { a = f(); }
a и f(), используемые g() в файле file2.c,- те же, что определены в
файле file1.c. Ключевое слово extern (внешнее) указывает, что
описание a в file2.c является (только) описанием, а не
определением. Если бы a инициализировалось, extern было бы просто
проигнорировано, поскольку описание с инициализацией всегда
является определением. Объект в программе должен определяться
только один раз. Описываться он может много раз, но типы должны
точно согласовываться. Например:
// file1.c:
int a = 1;
int b = 1;
extern int c;
// file2.c:
int a;
extern double b;
extern int c;
Здесь три ошибки: a определено дважды (int a; является
определением, которое означает int a=0;), b описано дважды с
разными типами, а c описано дважды, но не определено. Эти виды
ошибок (ошибки компоновки) не могут быть обнаружены компилятором,
который за один раз видит только один файл. Компоновщик, однако, их
обнаруживает.
Следующая программа не является C++ программой (хотя C программой
является):
// file1.c:
int a;
int f() { return a; }
// file2.c:
int a;
int g() { return f(); }
Во-первых, file2.c не C++, потому что f() не была описана, и
поэтому компилятор будет недоволен. Во-вторых, (когда file2.c
фиксирован) программа не будет скомпонована, поскольку a определено
дважды.
Имя можно сделать локальным в файле, описав его static. Например:
// file1.c:
static int a = 6;
static int f() { /* ... */ }
// file2.c:
static int a = 7;
static int f() { /* ... */ }
Поскольку каждое a и f описано как static, получающаяся в
результате программа является правильной. В каждом файле своя a и
своя f().
Когда переменные и функции явно описаны как static, часть
программы легче понять (вам не надо никуда больше заглядывать).
Использование static для функций может, помимо этого, выгодно
влиять на расходы по вызову функции, поскольку дает оптимизирующему
компилятору более простую работу.
Рассмотрим два файла:
// file1.c:
const int a = 6;
inline int f() { /* ... */ }
struct s { int a,b; }
// file1.c:
const int a = 7;
inline int f() { /* ... */ }
struct s { int a,b; }
Раз правило "ровно одно определение" применяется к константам,
inline-функциям и определениям функций так же, как оно применяется
к функциям и переменным, то file1.c и file2.c не могут быть частями
одной C++ программы. Но если это так, то как же два файла могут
использовать одни и те же типы и константы? Коротко, ответ таков:
типы, константы и т.п. могут определяться столько раз, сколько
нужно, при условии, что они определяются одинаково. Полный ответ
несколько более сложен (это объясняется в следующем разделе).
Типы во всех описаниях одного и того же объекта должны быть
согласованными. Один из способов это достичь мог бы состоять в
обеспечении средств проверки типов в компоновщике, но большинство
компоновщиков - образца 1950-х, и их нельзя изменить по
практическим соображениям *3. Другой подход состоит в обеспечении того, что исходный текст, как он передается на рассмотрение
компилятору, или согласован, или содержит информацию, которая
позволяет компилятору обнаружить несогласованности. Один
несовершенный, но простой способ достичь согласованности состоит во
включении заголовочных файлов, содержащих интерфейсную информацию,
в исходные файлы, в которых содержится исполняемый код и/или
определения данных.
Механизм включения с помощью #include - это чрезвычайно простое
средство обработки текста для сборки кусков исходной программы в
одну единицу (файл) для ее компиляции. Директива
#include "to_be_included"
замещает строку, в которой встретилось #include, содержимым файла
"to_be_included". Его содержимым должен быть исходный текст на C++,
поскольку дальше его будет читать компилятор. Часто включение
обрабатывается отдельной программой, называемой C препроцессором,
которую CC вызывает для преобразования исходного файла, который дал
программист, в файл без директив включения перед тем, как начать
собственно компиляцию. В другом варианте эти директивы обрабатывает
интерфейсная система компилятора по мере того, как они встречаются
в исходном тексте. Если программист хочет посмотреть на результат
директив включения, можно воспользоваться командой
CC -E file.c
для препроцессирования файла file.c точно также, как это сделала бы
CC перед запуском собственно компилятора. Для включения файлов из
стандартной директории включения вместо кавычек используются
угловые скобки < и >. Например:
#include // из стандартной директории включения
#define "myheader.h" // из текущей директории
Использование <> имеет то преимущество, что в программу
фактическое имя директории включения не встраивается (как правило,
сначала просматривается /usr/include/CC, а потом usr/include). К
сожалению, пробелы в директиве include существенны:
#include < stream.h > // не найдет
Может показаться, что перекомпилировать файл заново каждый раз,
когда он куда-либо включается, расточительно, но время компиляции
такого файла обычно слабо отличается от времени, которое необходимо
для чтения его некоторой заранее откомпилированной формы. Причина в
том, что текст программы является довольно компактным
представлением программы, и в том, что включаемые файлы обычно
содержат только описания и не содержат программ, требующих от
компилятора значительного анализа.
Следующее эмпирическое правило относительно того, что следует, а
что не следует помещать в заголовочные файлы, является не
требованием языка, а просто предложением по разумному использованию
аппарата #include.
В заголовочном файле могут содержаться:
Определения типов
struct point { int x, y; }
Описания функций
extern int strlen(const char*);
Определения inline-функций
inline char get() { return *p++; }
Описания данных
extern int a;
Определения констант
const float pi = 3.141593
Перечисления
enum bool { false, true };
Директивы include
#include
Определения макросов
#define Case break;case
Комментарии
/* проверка на конец файла */
но никогда
Определения обычных функций
char get() { return *p++; }
Определения данных
int a;
Определения сложных константных объектов
const tbl[] = { /* ... */ }
В системе UNIX принято, что заголовочные файлы имеют суффикс
(расширение) .h. Файлы, содержащие определение данных или функций,
должны иметь суффикс .c. Такие файлы часто называют,
соответственно, ".h файлы" и ".c файлы". В #4.7 описываются
макросы. Следует заметить, что в C++ макросы гораздо менее полезны,
чем в C, поскольку C++ имеет такие языковые конструкции, как const
для определения констант и inline для исключения расходов на вызов
функции.
Причина того, почему в заголовочных файлах допускается
определение простых констант, но не допускается определение сложных
константных объектов, прагматическая. В принципе, сложность тут
только в том, чтобы сделать допустимым дублирование определений
переменных (даже определения функций можно было бы дублировать).
Однако для компоновщиков старого образца слишком трудно проверять
тождественность нетривиальных констант и убирать ненужные повторы.
Кроме того, простые случаи гораздо более обиходны и потому более
важны для генерации хорошего кода.
4.3.1 Один Заголовочный Файл
Проще всего решить проблему разбиения программы на несколько
файлов поместив функции и определения данных в подходящее число
исходных файлов и описав типы, необходимые для их взаимодействия, в
одном заголовочном файле, который включается во все остальные
файлы. Для программы калькулятора можно использовать четыре .c
файла: lex.c, syn.c, table.c и main.c, и заголовочный файл dc.h,
содержащий описания всех имен, которые используются более чем в
одном .c файле:
Заметьте, что такое использование заголовочных файлов гарантирует,
что каждое описание в заголовочном файле объекта, определенного
пользователем, будет в какой-то момент включено в файл, где он
определяется. Например, при компиляции lex.c компилятору будет
передано:
Это обеспечивает то, что компилятор обнаружит любую
несогласованность в типах, указанных для имени. Например, если бы
get_token() была описана как возвращающая token_value, но при этом
определена как возвращающая int, компиляция lex.c не прошла бы из-
за ошибки несоответствия типов.
Файл syn.c будет выглядеть примерно так:
// table.c: таблица имен и просмотр
#include "dc.h"
extern char* strcmp(const char*, const char*);
extern char* strcpy(char*, const char*);
extern int strlen(const char*);
const TBLSZ = 23;
name* table[TBLSZ];
name* look(char* p; int ins) { /* ... */ }
Заметьте, что table.c сам описывает стандартные функции для
работы со строками, поэтому никакой проверки согласованности этих
описаний нет. Почти всегда лучше включать заголовочный файл, чем
описывать имя в .c файле как extern. При этом может включаться
"слишком много", но это обычно не оказывает серьезного влияния на
время, необходимое для компиляции, и как правило экономит время
программиста. В качестве примера этого, обратите внимание на то,
как strlen() заново описывается в main() (ниже). Это лишние нажатия
клавиш и возможный источник неприятностей, поскольку компилятор не
может проверить согласованность этих двух определений. На самом
деле, этой сложности можно было бы избежать, будь все описания
extern помещены в dc.h, как и предлагалось сделать. Эта
"небрежность" сохранена в программе, поскольку это очень типично
для C программ, очень соблазнительно для программиста, и чаще
приводит, чем не приводит, к ошибкам, которые трудно обнаружить, и
к программам, с которыми тяжело работать. Вас предупредили!
И main.c, наконец, выглядит так:
// main.c: инициализация, главный цикл и обработка ошибок
#include "dc.h"
int no_of_errors;
double error(char* s) { /* ... */ }
extern int strlen(const char*);
main(int argc, char* argv[]) { /* ... */ }
Важный случай, когда размер заголовочных файлов становится
серьезной помехой. Набор заголовочных файлов и библиотеку можно
использовать для расширения языка множеством обще- и специально-
прикладных типов (см. Главы 5-8). В таких случаях не принято
осуществлять чтение тысяч строк заголовочных файлов в начале каждой
компиляции. Содержание этих файлов обычно "заморожено" и изменяется
очень нечасто. Наиболее полезным может оказаться метод затравки
компилятора содержанием этих заголовочных фалов. По сути, создается
язык специального назначения со своим собственным компилятором.
Никакого стандартного метода создания такого компилятора с
затравкой не принято.
4.3.2 Множественные Заголовочные Файлы
Стиль разбиения программы с одним заголовочным файлом наиболее
пригоден в тех случаях, когда программа невелика и ее части не
предполагается использовать отдельно. Поэтому то, что невозможно
установить, какие описания зачем помещены в заголовочный файл,
несущественно. Помочь могут комментарии. Другой способ - сделать
так, чтобы каждая часть программы имела свой заголовочный файл, в
котором определяются предоставляемые этой частью средства. Тогда
каждый .c файл имеет соответствующий .h файл, и каждый .c файл
включает свой собственный (специфицирующий то, что в нем задается)
.h файл и, возможно, некоторые другие .h файлы (специфицирующие то,
что ему нужно).
Рассматривая организацию калькулятора, мы замечаем, что error()
используется почти каждой функцией программы, а сама использует
только . Это обычная для функции ошибок ситуация, поэтому
error() следует отделить от main():
При таком стиле использования заголовочных файлов .h файл и
связанный с ним .c файл можно рассматривать как модуль, в котором
.h файл задает интерфейс, а .c файл задает реализацию.
Таблица символов не зависит от остальной части калькулятора за
исключением использования функции ошибок. Это можно сделать явным:
// table.h: описания таблицы имен
struct name {
char* string;
name* next;
double value;
};
extern name* look(char* p, int ins = 0);
inline name* insert(char* s) { return look(s,1); }
// table.c: определения таблицы имен
#include "error.h"
#include
#include "table.h"
const TBLSZ = 23;
name* table[TBLSZ];
name* look(char* p; int ins) { /* ... */ }
Заметьте, что описания функций работы со строками теперь
включаются из . Это исключает еще один возможный источник
ошибок.
// lex.h: описания для ввода и лексического анализа
enum token_value {
NAME, NUMBER, END,
PLUS='+', MINUS='-', MUL='*', DIV='/',
PRINT=';', ASSIGN='=', LP='(', RP=')'
};
extern token_value curr_tok;
extern double number_value;
extern char name_string[256];
extern token_value get_token();
Этот интерфейс лексического анализатора достаточно беспорядочен.
Недостаток в надлежащем типе лексемы обнаруживает себя в
необходимости давать пользователю get_token() фактические
лексические буферы number_value и name_string.
// lex.c: определения для ввода и лексического анализа
#include
#include
#include "error.h"
#include "lex.h"
token_value curr_tok;
double number_value;
char name_string[256];
token_value get_token() { /* ... */ }
Интерфейс синтаксического анализатора совершенно прозрачен:
// syn.c: описания для синтаксического анализа и вычисления
extern double expr();
extern double term();
extern double prim();
// syn.c: определения для синтаксического анализа и вычисления
#include "error.h"
#include "lex.h"
#include "syn.h"
double prim() { /* ... */ }
double term() { /* ... */ }
double expr() { /* ... */ }
Сколько заголовочных файлов использовать в программе, зависит от
многих факторов. Многие из этих факторов сильнее связаны с тем, как
ваша система работает с заголовочными файлами, нежели с C++.
Например, если в вашем редакторе нет средств, позволяющих
одновременно видеть несколько файлов, использование большого числа
файлов становится менее привлекательным. Аналогично, если
открывание и чтение 10 файлов по 50 строк в каждом требует заметно
больше времени, чем чтение одного файла в 500 строк, вы можете
дважды подумать, прежде чем использовать в небольшом проекте стиль
множественных заголовочных файлов. Слово предостережения: набор из
десяти заголовочных файлов плюс стандартные заголовочные файлы
обычно легче поддаются управлению. С другой стороны, если вы
разбили описания в большой программе на логически минимальные по
размеру заголовочные файлы (помещая каждое описание структуры в
свой отдельный файл и т.д.), у вас легко может получиться
неразбериха из сотен файлов.
4.3.3 Скрытие Данных
Используя заголовочные файлы пользователь может определять явный
интерфейс, чтобы обеспечить согласованное использование типов в
программе. С другой стороны, пользователь может обойти интерфейс,
задаваемый заголовочным файлом, вводя в .c файлы описания extern.
Заметьте, что такой стиль компоновки не рекомендуется:
// file1.c: // "extern" не используется
int a = 7;
const c = 8;
void f(long) { /* ... */ }
// file2.c: // "extern" в .c файле
extern int a;
extern const c;
extern f(int);
int g() { return f(a+c); }
Поскольку описания extern в file2.c не включаются вместе с
определениями в файле file1.c, компилятор не может проверить
согласованность этой программы. Следовательно, если только
загрузчик не окажется гораздо сообразительнее среднего, две ошибки
в этой программе останутся, и их придется искать программисту.
Пользователь может защитить файл от такой недисциплинированной
компоновки, описав имена, которые не предназначены для общего
пользования, как static, чтобы их областью видимости был файл, и
они были скрыты от остальных частей программы. Например:
Это гарантирует, что любой доступ к table действительно будет
осуществляться именно через look(). "Прятать" константу TBLSZ не
обязательно.
4.4 Файлы как Модули
В предыдущем разделе .c и .h файлы вместе определяли часть
программы. Файл .h является интерфейсом, который используют другие
части программы; .c файл задает реализацию. Такой объект часто
называют модулем. Доступными делаются только те имена, которые
необходимо знать пользователю, остальные скрыты. Это качество часто
называют скрытием данных, хотя данные - лишь часть того, что может
быть скрыто. Модули такого вида обеспечивают большую гибкость.
Например, реализация может состоять из одного или более .c файлов,
и в виде .h файлов может быть предоставлено несколько интерфейсов.
Информация, которую пользователю знать не обязательно, искусно
скрыта в .c файлах. Если важно, что пользователь не должен точно
знать, что содержится в .c файлах, не надо делать их доступными в
исходом виде. Достаточно эквивалентных им выходных файлов
компилятора (.o файлов).
Иногда возникает сложность, состоящая в том, что подобная
гибкость достигается без формальной структуры. Сам язык не
распознает такой модуль как объект, и у компилятора нет возможности
отличить .h файлы, определяющие имена, которые должны использовать
другие модули (экспортируемые), от .h файлов, которые описывают
имена из других модулей (импортируемые).
В других случаях может возникнуть та проблема, что модуль
определяет множество объектов, а не новый тип. Например, модуль
table определяет одну таблицу, и если вам нужно две таблицы, то нет
простого способа задать вторую таблицу с помощью понятия модуля.
Решение этой проблемы приводится в Главе 5.
Каждый статически размещенный объект по умолчанию
инициализируется нулем, программист может задать другие
(константные) значения. Это только самый примитивный вид
инициализации. К счастью, с помощью классов можно задать код,
который выполняется для инициализации перед тем, как модуль каким-
либо образом используется, и/или код, который запускается для
очистки после последнего использования модуля; см. #5.5.2.
4.5 Как Создать Библиотеку
Фразы типа "помещен в библиотеку" и "ищется в какой-то
библиотеке" используются часто (и в этой книге, и в других), но что
это означает для C++ программы? К сожалению, ответ зависит от того,
какая операционная система используется; в этом разделе
объясняется, как создать библиотеку в 8-ой версии системы UNIX.
Другие системы предоставляют аналогичные возможности.
Библиотека в своей основе является множеством .o файлов,
полученных в результате компиляции соответствующего множества .c
файлов. Обычно имеется один или более .h файлов, в которых
содержатся описания для использования этих .o файлов. В качестве
примера рассмотрим случай, когда нам надо задать (обычным способом)
набор математических функций для некоторого неопределенного
множества пользователей. Заголовочный файл мог бы выглядеть
примерно так:
а определения этих функций хранились бы, соответственно, в файлах
sqrt.c, sin.c, cos.c, exp.c и log.c.
Библиотеку с именем math.h можно создать, например, так:
$ CC -c sqrt.c sin.c cos.c exp.c log.c
$ ar cr math.a sqrt.o sin.o cos.o exp.o log.o
$ ranlib math.a
Вначале исходные файлы компилируются в эквивалентные им объектные
файлы. Затем используется команда ar, чтобы создать архив с именем
math.a. И, наконец, этот архив индексируется для ускорения доступа.
Если в вашей системе нет команды runlib, значит она вам, вероятно,
не понадобится. Подробности посмотрите, пожалуйста, в вашем
руководстве в разделе под заголовком ar. Использовать библиотеку
можно, например, так:
$ CC myprog.c math.a
Теперь разберемся, в чем же преимущества использования math.a
перед просто непосредственным использованием .o файлов? Например:
$ CC myprog.c sqrt.o sin.o cos.o exp.o log.o
Для большинства программ определить правильный набор .o файлов,
несомненно, непросто. В приведенном выше примере они включались
все, но если функции в myprog.c вызывают только функции sqrt() и
cos(), то кажется, что будет достаточно
$ CC myprog.c sqrt.o cos.o
Но это не так, поскольку cos.c использует sin.c.
Компоновщик, вызываемый командой CC для обработки .a файла (в
данном случае, файла math.a) знает, как из того множества, которое
использовалось для создания .a файла, извлечь только необходимые .o
файлы.
Другими словами, используя библиотеку можно включать много
определений с помощью одного имени (включения определений функций и
переменных, используемых внутренними функциями, никогда не видны
пользователю), и, кроме того, обеспечить, что в результате в
программу будет включено минимальное количество определений.
Обычный способ сделать что-либо в C++ программе - это вызвать
функцию, которая это делает. Определение функции является способом
задать то, как должно делаться некоторое действие. Функция не может
быть вызвана, пока она не описана.
4.6.1 Описания Функций
Описание функции задает имя функции, тип возвращаемого функцией
значения (если таковое есть) и число и типы параметров, которые
должны быть в вызове функции. Например:
Семантика передачи параметров идентична семантике инициализации.
Проверяются типы параметров, и когда нужно производится неявное
преобразование типа. Например, если были заданы предыдущие
определения, то
double sr2 = sqrt(2);
будет правильно обращаться к функции sqrt() со значением с
плавающей точкой 2.0. Значение такой проверки типа и преобразования
типа огромно.
Описание функции может содержать имена параметров. Это может
помочь читателю, но компилятор эти имена просто игнорирует.
4.6.2 Определения Функций
Каждая функция, вызываемая в программе, должна быть где-то
определена (только один раз). Определение функции - это описание
функции, в котором приводится тело функции. Например:
extern void swap(int*, int*); // описание
void swap(int*, int*) // определение
{
int t = *p;
*p =*q;
*q = t;
}
Чтобы избежать расходов на вызов функции, функцию можно описать
как inline (#1.12), а чтобы обеспечить более быстрый доступ к
параметрам, их можно описать как register (#2.3.11). Оба средства могут использоваться неправильно, и их следует избегать везде где
есть какие-либо сомнения в их полезности.
4.6.3 Передача Параметров
Когда вызывается функция, дополнительно выделяется память под ее
формальные параметры, и каждый формальный параметр инициализируется
соответствующим ему фактическим параметром. Семантика передачи
параметров идентична семантике инициализации. В частности, тип
фактического параметра сопоставляется с типом формального
параметра, и выполняются все стандартные и определенные
пользователем преобразования типов. Есть особые правила для
передачи векторов (#4.6.5), средство передавать параметр без проверки (#4.6.8) и средство для задания параметров по умолчанию (#4.6.6). Рассмотрим
void f(int val, int& ref)
{
val++;
ref++;
}
Когда вызывается f(), val++ увеличивает локальную копию первого
фактического параметра, тогда как ref++ увеличивает второй
фактический параметр. Например:
int i = 1;
int j = 1;
f(i,j);
увеличивает j, но не i. Первый параметр, i, передается по значению,
второй параметр, j, передается по ссылке. Как уже отмечалось в
#2.3.10, использование функций, которые изменяют переданные по ссылке параметры, могут сделать программу трудно читаемой, и их
следует избегать (но см. #6.5 и #8.4). Однако передача большого объекта по ссылке может быть гораздо эффективнее, чем передача его по значению. В этом случае параметр можно описать как const, чтобы
указать, что ссылка применяется по соображениям эффективности, а
также чтобы не позволить вызываемой функции изменять значение
объекта:
void f(const large& arg)
{
// значение "arg" не может быть изменено
}
Аналогично, описание параметра указателя как const сообщает
читателю, что значение объекта, указываемого указателем, функцией
не изменяется. Например:
extern int strlen(const char*); // из
extern char* strcpy(char* to, const char* from);
extern int strcmp(const char*, const char*);
Важность такой практики растет с размером программы.
Заметьте, что семантика передачи параметров отлична от семантики
присваивания. Это важно для const параметров, ссылочных параметров
и параметров некоторых типов, определяемых пользователем (#6.6).
4.6.4 Возврат Значения
Из функции, которая не описана как void, можно (и должно)
возвращать значение. Возвращаемое значение задается оператором
return. Например:
int fac(int n) {return (n>1) ? n*fac(n-1) : 1; }
В функции может быть больше одного оператора return:
int fac(int n)
{
if (n > 1)
return n*fac(n-1);
else
return 1;
}
Как и семантика передачи параметров, семантика возврата функцией
значения идентична семантике инициализации. Возвращаемое значение
рассматривается как инициализатор переменной возвращаемого типа.
Тип возвращаемого выражения проверяется на согласованность с
возвращаемым типом и выполняются все стандартные и определенные
пользователем преобразования типов. Например:
Каждый раз, когда вызывается функция, создается новая копия ее
параметров и автоматических переменных. После возврата из функции
память используется заново, поэтому возвращать указатель на
локальную переменную неразумно. Содержание указываемого места
изменится непредсказуемо:
int* f() {
int local = 1;
// ...
return &local; // так не делайте
}
Эта ошибка менее обычна, чем эквивалентная ошибка при использовании
ссылок:
int& f() {
int local = 1;
// ...
return local; // так не делайте
}
К счастью, о таких возвращаемых значениях предупреждает компилятор.
Вот другой пример:
int& f() { return 1;} // так не делайте
4.6.5 Векторные Параметры
Если в качестве параметра функции используется вектор, то
передается указатель на его первый элемент. Например:
int strlen(const char*);
void f()
{
char v[] = "a vector"
strlen(v);
strlen("Nicholas");
};
Иначе говоря, при передаче как параметр типа T[]
преобразуется к T*. Следовательно, присваивание элементу векторного
параметра изменяет значение элемента вектора, который является
параметром. Другими словами, вектор отличается от всех остальных
типов тем, что вектор не передается (и не может передаваться) по
значению.
Размер вектора недоступен вызываемой функции. Это может быть
неудобно, но эту сложность можно обойти несколькими способами.
Строки оканчиваются нулем, поэтому их размер можно легко вычислить.
Для других векторов можно передавать второй параметр, который
задает размер, или определить тип, содержащий указатель и индикатор
длины, и передавать его вместо просто вектора (см. также #1.11).
Например:
void compute1(int* vec_ptr, int vec_size); // один способ
struct vec { // другой способ
int* ptr;
int size;
};
void compute2(vec v);
С многомерными массивами все хитрее, но часто можно вместо них
использовать векторы указателей, которые не требуют специального
рассмотрения. Например:
С другой стороны, рассмотрим определение функции, которая
работает с двумерными матрицами. Если размерность известна на
стадии компиляции, то никаких проблем нет:
void print_m34(int m[3][4])
{
for (int i = 0; i<3; i++) {
for (int j = 0; j<4; j++)
cout << " " << m[i][j];
cout << "\n";
}
}
Матрица, конечно, все равно передается как указатель, а
размерности используются просто для удобства записи.
Первая размерность массива не имеет отношения к задаче отыскания
положения элемента (#2.3.6). Поэтому ее можно передавать как параметр:
void print_mi4(int m[][4], int dim1)
{
for (int i = 0; i
4.6.6 Параметры по Умолчанию
Часто в самом общем случае функции требуется больше параметров,
чем в самом простом и более употребительном случае. Например, в
библиотеке потоков есть функция hex(), порождающая строку с
шестнадцатиричным представлением целого. Второй параметр
используется для задания числа символов для представления первого
параметра. Если число символов слишком мало для представления
целого, происходит усечение, если оно слишком велико, то строка
дополняется пробелами. Часто программист не заботится о числе
символов, необходимых для представления целого, поскольку символов
достаточно. Поэтому для нуля в качестве второго параметра
определено значение "использовать столько символов, сколько нужно".
Чтобы избежать засорения программы вызовами вроде hex(i,0), функция
описывается так:
extern char* hex(long, int =0);
Инициализатор второго параметра является параметром по умолчанию.
То есть, если в вызове дан только один параметр, в качестве второго
используется параметр по умолчанию. Например:
cout << "**" << hex(31) << hex(32,3) << "**";
интерпретируется как
cout << "**" << hex(31,0) << hex(32,3) << "**";
и напечатает:
** 1f 20**
Параметр по умолчанию проходит проверку типа во время описания
функции и вычисляется во время ее вызова. Задавать параметр по
умолчанию возможно только для последних параметров, поэтому
int f(int, int =0, char* =0); // ok
int g(int =0, int =0, char*); // ошибка
int f(int =0, int, char* =0); // ошибка
Заметьте, что в этом контексте пробел между * и = является
существенным (*= является операцией присваивания):
int nasty(char*=0); // синтаксическая ошибка
4.6.7 Перегрузка Имен Функций
Как правило, давать разным функциям разные имена - мысль хорошая,
но когда некоторые функции выполняют одинаковую работу над
объектами разных типов, может быть более удобно дать им одно и то
же имя. Использование одного имени для различных действий над
различными типами называется перегрузкой (overloading). Метод уже
используется для основных операций C++: у сложения существует
только одно имя, +, но его можно применять для сложения значений
целых, плавающих и указательных типов. Эта идея легко расширяется
на обработку операций, определенных пользователем, то есть,
функций. Чтобы уберечь программиста от случайного повторного
использования имени, имя может использоваться более чем для одной
функции только если оно сперва описано как перегруженное. Например:
Что касается компилятора, единственное общее, что имеют функции с
одинаковым именем, это имя. Предположительно, они в каком-то смысле
похожи, но в этом язык ни стесняет программиста, ни помогает ему.
Таким образом, перегруженные имена функций - это главным образом
удобство записи. Это удобство значительно в случае функций с
общепринятыми именами вроде sqrt, print и open. Когда имя
семантически значимо, как это имеет место для операций вроде +, *
и << (#6.2) и в случае конструкторов (#5.2.4 и #6.3.1), это
удобство становится существенным. Когда вызывается перегруженная
f(), компилятор должен понять, к какой из функций с именем f
следует обратиться. Это делается путем сравнения типов фактических
параметров с типами формальных параметров всех функций с именем f.
Поиск функции, которую надо вызвать, осуществляется за три
отдельных шага:
[1] Искать функцию соответствующую точно, и использовать ее, если
она найдена;
[2] Искать соответствующую функцию используя встроенные
преобразования и использовать любую найденную функцию; и
[3] Искать соответствующую функцию используя преобразования,
определенные пользователем (#6.3), и если множество
преобразований единственно, использовать найденную функцию.
Например:
Правило точного соответствия гарантирует, что f напечатает 1 как
целое и 1.0 как число с плавающей точкой. Ноль, char или short
точно соответствуют параметру int. Аналогично, float точно
соответствует double.
К параметрам функций с перегруженными именами стандартные C++
правила преобразования (#с.6.6) применяются не полностью.
Преобразования, могущие уничтожить информацию, не выполняются.
Остаются int в long, int в double, ноль в long, ноль в double и
преобразования указателей: ноль в указатель, ноль в void*, и
указатель на производный класс в указатель на базовый класс
(#7.2.4).
Вот пример, в котором преобразование необходимо:
Здесь a может быть напечатано или как double, или как long.
Неоднозначность разрешается явным преобразованием типа (или
print(long(a)) или print(double(a))).
При этих правилах можно гарантировать, что когда эффективность
или точность вычислений для используемых типов существенно
различаются, будет использоваться простейший алгоритм (функция).
Например:
overload pow;
int pow(int, int);
double pow(double, double); // из
complex pow(double, complex); // из
complex pow(complex, int);
complex pow(complex, double);
complex pow(complex, complex);
Процесс поиска подходящей функции игнорирует unsigned и const.
4.6.8 Незаданное Число Параметров
Для некоторых функций невозможно задать число и тип всех
параметров, которые можно ожидать в вызове. Такую функцию описывают
завершая список описаний параметров многоточием (...), что означает
"и может быть, еще какие-то параметры". Например:
int printf(char* ...);
Это задает, что в вызове printf должен быть по меньшей мере один
параметр, char*, а остальные могут быть, а могут и не быть.
Например:
Такая функция полагается на информацию, которая недоступна
компилятору при интерпретации ее списка параметров. В случае
printf() первым параметром является строка формата, содержащая
специальные последовательности символов, позволяющие printf()
правильно обрабатывать остальные параметры. %s означает "жди
параметра char*", а %d означает "жди параметра int". Однако,
компилятор этого не знает, поэтому он не может убедиться в том, что
ожидаемые параметры имеют соответствующий тип. Например:
printf("Мое имя %s %s\n",2);
откомпилируется и в лучшем случае приведет к какой-нибудь странного
вида выдаче.
Очевидно, если параметр не был описан, то у компилятора нет
информации, необходимой для выполнения над ним проверки типа и
преобразования типа. В этом случае char или short передаются как
int, а float передается как double. Это не обязательно то, чего
ждет пользователь.
Чрезмерное использование многоточий, вроде wild(...), полностью
выключает проверку типов параметров, оставляя программиста открытым
перед множеством неприятностей, которые хорошо знакомы
программистам на C. В хорошо продуманной программе требуется самое
большее несколько функций, для которых типы параметров не
определены полностью. Для того, чтобы позаботиться о проверке
типов, можно использовать перегруженные функции и функции с
параметрами по умолчанию в большинстве тех случаев, когда иначе
пришлось бы оставить типы параметров незаданными. Многоточие
необходимо только если изменяются и число параметров, и тип
параметров. Наиболее обычное применение многоточия в задании
интерфейса с функциями C библиотек, которые были определены в то
время, когда альтернативы не было:
extern int fprintf(FILE*, char* ...); // из
extern int execl(char* ...); // из
extern int abort(...); // из
Разберем случай написания функции ошибок, которая
получает один целый параметр, указывающий серьезность ошибки, после
которого идет произвольное число строк. Идея состоит в том, чтобы
составлять сообщение об ошибке с помощью передачи каждого слова как
отдельного строкового параметра:
#include
void error(int n ...)
/*
"n" с последующим списком char*, оканчивающихся нулем
*/
{
va_list ap;
va_start(ap,n); // раскрутка arg
for (;;) {
char* p = va_arg(ap,char*);
if(p == 0) break;
cerr << p << " ";
}
va_end(ap); // очистка arg
cerr << "\n";
if (n) exit(n);
}
Первый из va_list определяется и инициализируется вызовом
va_start(). Макрос va_start получает имя va_list'а и имя последнего
формального параметра как параметры. Макрос va_arg используется для
выбора неименованных параметров по порядку. При каждом обращении
программист должен задать тип; va_arg() предполагает, что был
передан фактический параметр, но обычно способа убедиться в этом
нет. Перед возвратом из функции, в которой был использован
va_start(), должен быть вызван va_end(). Причина в том, что
va_start() может изменить стек так, что нельзя будет успешно
осуществить возврат; va_end() аннулирует все эти изменения.
4.6.9 Указатель на Функцию
С функцией можно делать только две вещи: вызывать ее и брать ее
адрес. Указатель, полученный взятием адреса функции, можно затем
использовать для вызова этой функции. Например:
void error(char* p) { /* ... */ }
void (*efct)(char*); // указатель на функцию
void f()
{
efct = &error; // efct указывает на error
(*efct)("error"); // вызов error через efct
}
Чтобы вызвать функцию через указатель, например, efct, надо сначала
этот указатель разыменовать, *efct. Поскольку операция вызова
функции () имеет более высокий приоритет, чем операция
разыменования *, то нельзя писать просто *efct("error"). Это
означает *efct("error"), а это ошибка в типе. То же относится и к
синтаксису описаний (см. также #7.3.4).
Заметьте, что у указателей на функции типы параметров описываются
точно также, как и в самих функциях. В присваиваниях указателя
должно соблюдаться точное соответствие полного типа функции.
Например:
void (*pf)(char*); // указатель на void(char*)
void f1(char*); // void(char*)
int f2(char*); // int(char*)
void f3(int*); // void(int*)
void f()
{
pf = &f1; // ok
pf = &f2; // ошибка: не подходит возвращаемый тип
pf = &f3; // ошибка: не подходит тип параметра
(*pf)("asdf"); // ok
(*pf)(1); // ошибка: не подходит тип параметра
int i = (*pf)("qwer"); // ошибка: void присваивается int'у
}
Правила передачи параметров для непосредственных вызовов функции и
для вызовов функции через указатель одни и те же.
Часто, чтобы избежать использования какого-либо неочевидного
синтаксиса, бывает удобно определить имя типа указатель-на-функцию.
Например:
typedef int (*SIG_TYP)(); // из
typedef void (*SIG_ARG_TYP);
SIG_TYP signal(int,SIG_ARG_TYP);
Бывает часто полезен вектор указателей на функцию. Например,
система меню для моего редактора с мышью*4 реализована с помощью векторов указателей на функции для представления действий. Подробно эту систему здесь описать не получится, но вот общая идея:
Затем определяем и инициализируем указатели, определяющие действия,
выбранные в меню, которое связано с кнопками (button) мыши:
PF* button2 = edit_ops;
PF* button3 = file_ops;
В полной реализации для определения каждого пункта меню требуется
больше информации. Например, где-то должна храниться строка,
задающая текст, который высвечивается. При использовании системы
значение кнопок мыши часто меняется в зависимости от ситуации. Эти
изменения осуществляются (частично) посредством смены значений
указателей кнопок. Когда пользователь выбирает пункт меню, например
пункт 3 для кнопки 2, выполняется связанное с ним действие:
(button2[3])();
Один из способов оценить огромную мощь указателей на функции - это
попробовать написать такую систему не используя их. Меню можно
менять в ходе использования программы, внося новые функции в
таблицу действий. Во время выполнения можно также легко
сконструировать новое меню.
Указатели на функции можно использовать для задания полиморфных
подпрограмм, то есть подпрограмм, которые могут применяться к
объектам многих различных типов:
typedef int (*CFT)(char*,char*);
int sort(char* base, unsigned n, int sz, CFT cmp)
/*
Сортирует "n" элементов вектора "base"
в возрастающем порядке
с помощью функции сравнения, указываемой "cmp".
Размер элементов "sz".
Очень неэффективный алгоритм: пузырьковая сортировка
*/
{
for (int i=0; iname, Puser(q)->name);
}
int cmp2(char*p, char* q) // Сравнивает числа dept
{
return Puser(p)->dept-Puser(q)->dept;
}
Эта программа сортирует и печатает:
main ()
{
sort((char*)heads,6,sizeof(user),cmp1);
print_id(heads,6); // в алфавитном порядке
cout << "\n";
sort((char*)heads,6,sizeof(user),cmp2);
print_id(heads,6); // по порядку подразделений
}
Можно взять адрес inline-функции, как, впрочем, и адрес
перегруженной функции(#с.8.9).
4.7 Макросы
Макросы *5 определяются в #с.11. В C они очень важны, но в C++ применяются гораздо меньше. Первое правило относительно них такое:
не используйте их, если вы не обязаны это делать. Как было
замечено, почти каждый макрос проявляет свой изъян или в языке, или
в программе. Если вы хотите использовать макросы, прочитайте,
пожалуйста, вначале очень внимательно руководство по вашей
реализации C препроцессора.
Простой макрос определяется так:
#define name rest of line
Когда name встречается как лексема, оно заменяется на rest of line.
Например:
named = name
после расширения даст:
named = rest of line
Можно также определить макрос с параметрами. Например:
#define mac(a,b) argument1: a argument2: b
При использовании mac должно даваться две строки параметра. После
расширения mac() они заменяют a и b. Например:
expanded = mac(foo bar, yuk yuk)
после расширения даст
expanded = argument1: foo bar argument2: yuk yuk
Макросы обрабатывают строки и о синтаксисе C++ знают очень мало,
а о типах C++ или областях видимости - ничего. Компилятор видит
только расширенную форму макроса, поэтому ошибка в макросе
диагностируется когда макрос расширен, а не когда он определен. В
результате этого возникают непонятные сообщения об ошибках.
Вот такими макросы могут быть вполне:
Чтобы увидеть, чем они опасны, попробуйте провести расширения в
следующем примере:
int xx = 0; // глобальный счетчик
void f() {
int xx = 0; // локальная переменная
xx = SQUARE(xx+2); // xx = xx+2*xx+2
INCR_xx; // увеличивает локальный xx
if (a-DISP==b) { // a-= 4==b
// ...
}
}
Если вы вынуждены использовать макрос, при ссылке на глобальные
имена используйте операцию разрешения области видимости :: (#2.1.1) и заключайте вхождения имени параметра макроса в скобки везде, где это возможно (см. MIN выше).
Обратите внимание на различие результатов расширения этих двух
макросов:
int a = something(1) // глубокомысленный комментарий+2;
int b = something(1) /* глубокомысленный комментарий */+2;
С помощью макросов вы можете разработать свой собственный язык.
Скорее всего, для всех остальных он будет непостижим. Кроме того, C
препроцессор - очень простой макропроцессор. Когда вы попытаетесь
сделать что-либо нетривиальное, вы, вероятно, обнаружите, что
сделать это либо невозможно, либо чрезвычайно трудно (но см.
#7.3.5).
4.8 Упражнения
(*1) Напишите следующие описания: функция, получающая параметр
типа указатель на символ и ссылку на целое и не возвращающая
значения; указатель на такую функцию; функция, получающая такой
указатель в качестве параметра; и функция, возвращающая такой
указатель. Напишите определение функции, которая получает
такой указатель как параметр и возвращает свой параметр как
возвращаемое значение. Подсказка: используйте typedef.
(*1) Что это значит? Для чего это может использоваться?
typedef int (rifii&) (int, int);
(*1.5) Напишите программу вроде "Hello, world", которая
получает имя как параметр командной строки и печатает "Hello,
имя". Модифицируйте эту программу так, чтобы она получала
получала любое количество имен и говорила hello каждому из
них.
(*1.5) Напишите программу, которая читает произвольное число
файлов, имена которых задаются как аргументы командной стоки,
и пишет их один за другим в cout. Поскольку эта программа при
выдаче конкатенирует свои параметры, вы можете назвать ее cat
(кошка).
(*2) Преобразуйте небольшую C программу в C++. Измените
заголовочные файлы так, чтобы описывать все вызываемые функции
и описывать тип каждого параметра. Замените, где возможно,
директивы #define на enum и const или inline. Уберите из .c
файлов описания extern и преобразуйте определения функций к
синтаксису C++. Замените вызовы malloc() и free() на new и
delete. Уберите необязательные приведения типа.
(*2) Реализуйте sort() (#4.6.7) используя эффективный алгоритм сортировки.
(*2) Посмотрите на определение struct tnode в с.#8.5. Напишите функцию для введения новых слов в дерево узлов tnode.
Напишите функцию для вывода дерева узлов tnode. Напишите
функцию для вывода дерева узлов tnode со словами в алфавитном
порядке. Модифицируйте tnode так, чтобы в нем хранился
(только) указатель на слово произвольной длины, помещенное с
помощью new в свободную память. Модифицируйте функции для
использования нового определения tnode.
(*2) Напишите "модуль", реализующий стек. Файл .h должен
описывать функции push(), pop() и любые другие удобные функции
(только). Файл .c определяет функции и данные, необходимые для
хранения стека.
(*2) Узнайте, какие у вас есть стандартные заголовочные
файлы. Составьте список файлов, находящихся в /usr/include и
/usr/include/CC (или там, где хранятся стандартные заголовочные
файлы в вашей системе). Прочитайте все, что покажется
интересным.
(*2) Напишите функцию для обращения двумерного массива.
(*2) Напишите шифрующую программу, которая читает из cin и
пишет в cout закодированные символы. Вы можете воспользоваться
следующей простой схемой шифровки: Зашифрованная форма символа
c - это c^key[i], где key (ключ) - строка, которая передается
как параметр командной строки. Программа использует символы из
key циклически, пока не будет считан весь ввод.
Перекодирование зашифрованного текста с той же строкой key
дает исходный текст. Если не передается никакого ключа (или
передается пустая строка), то никакого кодирования не
делается.
(*3) Напишите программу, которая поможет расшифровывать
тексты, зашифрованные описанным выше способом, не зная ключа.
Подсказка: David Kahn: The Code-Breakers, Macmillan, 1967, New
York, pp 207-213.
(*3) Напишите функцию error, которая получает форматную
строку в стиле printf, которая содержит директивы %s, %c и %d,
и произвольное количество параметров. Не используйте printf().
Если вы не знаете значения %s и т.д., посмотрите #8.2.4.
Используйте .
(*1) Как вы будете выбирать имя для указателя на тип функции,
определенный с помощью typedef?
(*2) Посмотрите какие-нибудь программы, чтобы создать
представление о разнообразии стилей и имен, использующихся на
практике. Как используются буквы в верхнем регистре? Как
используется подчерк? Где используются короткие имена вроде x
и y?
(*1) Что неправильно в следующих макроопределениях?
#define PI = 3.141593
#define MAX(a,b) a>b?a:b
#define fac(a) (a)*fac((a)-1)
(*3) Напишите макропроцессор, который определяет и расширяет
простые макросы (как C препроцессор). Читайте из cin и пишите
в cout. Сначала не пытайтесь обрабатывать макросы с
параметрами. Подсказка: В настольном калькуляторе (#3.1) есть
таблица имен и лексический анализатор, которые вы можете
модифицировать.
*1 или линкер. (прим. перев.)
* 2C разработан так, чтобы в большинстве случаев позволять
осуществлять неявную компоновку. Применение C, однако, возросло
неимоверно, поэтому случаи, когда можно использовать неявную
линковку, сейчас составляют незначительное меньшинство. (прим.
автора)
*3 Легко изменить один компоновщик, но сделав это и написав
программу, которая зависит от усовершенствований, как вы будете
переносить эту программу в другое место? (прим. автора)
*4 Мышь - это указывающее устройство по крайней мере с одной
кнопкой. Моя мышь красная, круглая и с тремя кнопками. (прим.
автора)
*5 часто называемые также макроопределениями. (прим. перев.)
Знаете ли Вы, в чем фокус эксперимента Майкельсона?
Эксперимент А. Майкельсона, Майкельсона - Морли - действительно является цирковым фокусом, загипнотизировавшим физиков на 120 лет.
Дело в том, что в его постановке и выводах произведена подмена, аналогичная подмене в школьной шуточной задачке на сообразительность, в которой спрашивается: - Cколько яблок на березе, если на одной ветке их 5, на другой ветке - 10 и так далее При этом внимание учеников намеренно отвлекается от того основополагающего факта, что на березе яблоки не растут, в принципе.
В эксперименте Майкельсона ставится вопрос о движении эфира относительно покоящегося в лабораторной системе интерферометра. Однако, если мы ищем эфир, как базовую материю, из которой состоит всё вещество интерферометра, лаборатории, да и Земли в целом, то, естественно, эфир тоже будет неподвижен, так как земное вещество есть всего навсего определенным образом структурированный эфир, и никак не может двигаться относительно самого себя.
Удивительно, что этот цирковой трюк овладел на 120 лет умами физиков на полном серьезе, хотя его прототипы есть в сказках-небылицах всех народов всех времен, включая барона Мюнхаузена, вытащившего себя за волосы из болота, и призванных показать детям возможные жульничества и тем защитить их во взрослой жизни. Подробнее читайте в FAQ по эфирной физике.