Синтаксис операторов

 

оператор: описание

{ список-операторов opt }

выражение opt ;

 

if ( выражение ) оператор

if ( выражение ) оператор else оператор

switch ( выражение ) оператор

 

while ( выражение ) оператор

do оператор while ( выражение )

for (начальный-оператор-for выражение opt; выражение opt) оператор

 

case выражение-константа : оператор

default : оператор

break ; continue ;

return выражение opt ; goto идентификатор ;

идентификатор : оператор

 

список-операторов: оператор

список-операторов оператор

 

начальный-оператор-for: описание

выражение opt ;

 

Обратите внимание, что описание является оператором, но нет операторов присваивания или вызова функции (они относятся к выражениям).

 

 

3.3.1 Выбирающие операторы

Значение можно проверить с помощью операторов if или switch:

 

if ( выражение ) оператор

if ( выражение ) оператор else оператор

switch ( выражение ) оператор

 

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

 

== != < > <= >=

 

дают в результате целое 1, если отношение выполняется, и 0 в противном случае. Обычно определяют константы TRUE как 1 и FALSE как 0.

 

В операторе if, если выражение имеет ненулевое значение, выполняется первый оператор, а иначе выполняется второй (если он указан). Таким образом, в качестве условия допускается любое выражение типа целое или указатель. Пусть a целое, тогда

 

if (a) // ...

 

эквивалентно

 

if (a != 0) ...

 

Логические операции

 

&& || !

 

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

 

if (p && l<p->count) // ...

 

сначала проверяется значение p, и только если оно не равно нулю, то проверяется отношение l<p-

>count. Некоторые простые операторы if удобно заменять выражениями условия. Например, вместо оператора

 

if (a <= b) max = b;

else

max = a;

 

лучше использовать выражение

 

max = (a<=b) ? b : a;

 

Условие в выражении условия не обязательно окружать скобками, но если их использовать, то выражение становится понятнее.

 

Простой переключатель (switch) можно записать с помощью серии операторов if. Например,

 

switch (val) { case 1:

f();

break; case 2:

g();

break; default:

h();

break;

}

 

можно эквивалентно задать так:

 

if (val == 1) f();

else if (val == 2) g();

else

h();

 

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

 

Поэтому в нетривиальных случаях запись, использующая переключатель, понятнее.

 

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

 

switch (val) { // возможна ошибка

case 1:

cout << "case 1\n"; case 2:

cout << "case 2\n"; default:

cout << "default: case not found\n";

}

 

при val==1 напечатает к большому удивлению непосвященных:

 

case 1

case 2

default: case not found

 

Имеет смысл отметить в комментариях те редкие случаи, когда стандартный переход на следующий вариант оставлен намеренно. Тогда этот переход во всех остальных случаях можно смело считать ошибкой. Для завершения оператора в варианте чаще всего используется break, но иногда используются return и даже goto. Приведем пример:

 

switch (val) { // возможна ошибка

case 0:

cout << "case 0\n"; case1:

case 1:

cout << "case 1\n"; return;

case 2:

cout << "case 2\n"; goto case1;

default:

cout << "default: case not found\n"; return;

}

 

Здесь при значении val равном 2 мы получим:

 

case 2

case 1

 

Отметим, что метку варианта нельзя использовать в операторе goto:

 

goto case 2; // синтаксическая ошибка

 

 

3.3.2 Оператор goto

Презираемый оператор goto все-таки есть в С++:

 

goto идентификатор; идентификатор: оператор

 

Вообще говоря, он мало используется в языках высокого уровня, но может быть очень полезен, если текст на С++ создается не человеком, а автоматически, т.е. с помощью программы. Например, операторы goto используются при создании анализатора по заданной грамматике языка с помощью программных средств. Кроме того, операторы goto могут пригодиться в тех случаях, когда на первый план выходит скорость работы программы. Один из них - когда в реальном времени происходят какие- то вычисления во внутреннем цикле программы.

 

Есть немногие ситуации и в обычных программах, когда применение goto оправдано. Одна из них - выход из вложенного цикла или переключателя. Дело в том, что оператор break во вложенных циклах или переключателях позволяет перейти только на один уровень выше. Приведем пример:

 

void f()

{

int i; int j;

for ( i = 0; i < n; i++)

for (j = 0; j<m; j++)

if (nm[i][j] == a) goto found;

// здесь a не найдено

// ... found:

// nm[i][j] == a

}

 

Есть еще оператор continue, который позволяет перейти на конец цикла. Что это значит, объяснено в

$$3.1.5.

 

 

3.4 Комментарии и расположение текста

Программу гораздо легче читать, и она становится намного понятнее, если разумно использовать комментарии и систематически выделять текст программы пробелами. Есть несколько способов расположения текста программы, но нет причин считать, что один из них - наилучший. Хотя у каждого свой вкус. То же можно сказать и о комментариях.

 

Однако можно заполнить программу такими комментариями, что читать и понимать ее будет только труднее. Транслятор не в силах понять комментарий, поэтому он не может убедиться в том, что комментарий:

 

[1] осмысленный,

 

[2] действительно описывает программу, [3] не устарел.

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

 

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

 

// переменную "v" необходимо инициализировать.

// переменная "v" может использоваться только в функции "f()".

// до вызова любой функции из этого файла

// необходимо вызвать функцию "init()".

// в конце своей программы вызовите функцию "cleanup()".

// не используйте функцию "weird()".

// функция "f()" имеет два параметра.

 

При правильном программировании на С++ такие комментарии обычно оказываются излишними. Чтобы именно эти комментарии стали ненужными, можно воспользоваться правилами связывания ($$4.2) и областей видимости, а также правилами инициализации и уничтожения объектов класса ($$5.5).

 

Если некоторое утверждение выражается самой программой, не нужно повторять его в комментарии. Например:

 

a = b + c; // a принимает значение b+c

count++; // увеличим счетчик count

 

Такие комментарии хуже, чем избыточные. Они раздувают объем текста, затуманивают программу и могут быть даже ложными. В то же время комментарии именно такого рода используют для примеров в

 

учебниках по языкам программирования, подобных этой книге. Это одна из многих причин, по которой учебная программа отличается от настоящей.

 

Можно рекомендовать такой стиль введения комментариев в программу:

 

[1] начинать с комментария каждый файл программы: указать в общих чертах, что в ней определяется, дать ссылки на справочные руководства, общие идеи по сопровождению программы и т.д.;

 

[2] снабжать комментарием каждое определение класса или шаблона типа;

 

[3] комментировать каждую нетривиальную функцию, указав: ее назначение, используемый алгоритм (если только он неочевиден) и, возможно, предположения об окружении, в котором работает функция;

 

[4] комментировать определение каждой глобальной переменной;

 

[5] давать некоторое число комментариев в тех местах, где алгоритм неочевиден или непереносим;

 

[6] больше практически ничего. Приведем пример:

// tbl.c: Реализация таблицы имен.

/*

Использован метод Гаусса

см. Ральстон "Начальный курс по ..." стр. 411.

*/

// в swap() предполагается, что стек AT&T начинается с 3B20.

/************************************ Авторские права (c) 1991 AT&T, Inc Все права сохранены

**************************************/

 

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

 

Заметим, что если в функции используются только комментарии вида //, то любую ее часть можно сделать комментарием с помощью /* */, и наоборот.

 

 

3.5 Упражнения

1. (*1) Следующий цикл for перепишите с помощью оператора while:

 

for (i=0; i<max_length; i++)

if (input_line[i] == '?') quest_count++;

 

Запишите цикл, используя в качестве его управляющей переменной указатель так, чтобы условие имело вид *p=='?'.

 

2. (*1) Укажите порядок вычисления следующих выражений, задав полную скобочную структуру:

 

a = b + c * d << 2 & 8 a & 077 != 3

a == b || a == c && c < 5 c = x != 0

0 <= i < 7

f(1,2) + 3

a = - 1 + + b -- - 5

a = b == c ++

a = b = c = 0

a[4][2] *= * b ? c : * d * 2 a-b, c=d

 

3. (*2) Укажите 5 различных конструкций на С++, значение которых неопределено.

 

4. (*2) Приведите 10 разных примеров непереносимых конструкций на С++.

 

5. (*1) Что произойдет при делении на нуль в вашей программе на С++? Что будет в случае переполнения или потери значимости?

 

6. (*1) Укажите порядок вычисления следующих выражений, задав их полную скобочную структуру:

 

*p++

*--p

++a--

(int*)p->m

*p.m

*a[i]

 

7. (*2) Напишите такие функции: strlen() - подсчет длины строки, strcpy() - копирование строк и strcmp()

- сравнение строк. Какими должны быть типы параметров и результатов функций? Сравните их со стандартными версиями, имеющимися в <string.h> и в вашем руководстве.

 

8. (*1) Выясните, как ваш транслятор отреагирует на такие ошибки:

 

void f(int a, int b)

{

if (a = 3) // ...

if (a&077 == 0) // ... a := b+1;

}

 

Посмотрите, какова будет реакция на более простые ошибки.

 

9. (*2) Напишите функцию cat(), которая получает два параметра-строки и возвращает строку, являющуюся их конкатенацией. Для результирующей строки используйте память, отведенную с помощью new. Напишите функцию rev() для перевертывания строки, переданной ей в качестве параметра. Это означает, что после вызова rev(p) последний символ p станет первым и т.д.

 

10. (*2) Что делает следующая функция?

 

void send(register* to, register* from, register count)

// Псевдоустройство. Все комментарии сознательно удалены

{

register n=(count+7)/8; switch (count%8) {

case 0: do { *to++ = *from++; case 7: *to++ = *from++;

case 6: *to++ = *from++;

case 5: *to++ = *from++;

case 4: *to++ = *from++;

case 3: *to++ = *from++;

case 2: *to++ = *from++;

case 1: *to++ = *from++;

 

} while (--n>0);

}

}

 

Каков может быть смысл этой функции?

 

11. (*2) Напишите функцию atoi(), которая имеет параметр - строку цифр и возвращает соответствующее ей целое. Например, atoi("123") равно 123. Измените функцию atoi() так, чтобы она могла переводить в число последовательность цифр не только в десятичной, но и в восьмеричной и шестнадцатеричной записи, принятой в С++. Добавьте возможность перевода символьных констант С++. Напишите функцию itoa() для перевода целого значения в строковое представление.

 

12. (*2) Перепишите функцию get_token() ($$3.12) так, чтобы она читала целую строку в буфер, а затем

 

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

 

13. (*2) Введите в программу калькулятора из $$3.1 такие функции, как sqrt(), log() и sin(). Подсказка: задайте предопределенные имена и вызывайте функции с помощью массива указателей на них. Не забывайте проверять параметры, передаваемые этим функциям.

 

14. (*3) Введите в калькулятор возможность определять пользовательские функции. Подсказка: определите функцию как последовательность операторов, будто бы заданную самим пользователем. Эту последовательность можно хранить или как строку символов, или как список лексем. Когда вызывается функция, надо выбирать и выполнять операции. Если пользовательские функции могут иметь параметры, то придется придумать форму записи и для них.

 

15. (*1.5) Переделайте программу калькулятора, используя структуру symbol вместо статических переменных name_string и number_value:

 

struct symbol { token_value tok; union {

double number_value; char* name_string;

};

};

 

16. (*2.5) Напишите программу, которая удаляет все комментарии из программы на С++. Это значит, надо читать символы из cin и удалять комментарии двух видов: // и /* */. Получившийся текст запишите в cout. Не заботьтесь о красивом виде получившегося текста (это уже другая, более сложная задача). Корректность программ неважна. Нужно учитывать возможность появления символов //, /* и */ в комментариях, строках и символьных константах.

 

17. (*2) Исследуйте различные программы и выясните, какие способы выделения текста пробелами и какие комментарии используются.

 

ГЛАВА 4.

 

 

Итерация присуща человеку, а рекурсия - богу.

- Л. Дойч

 

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

 

 

4.1 Введение

Роль файла в языке С++ сводится к тому, что он определяет файловую область видимости ($$R.3.2). Это область видимости глобальных функций (как статических, так и подстановок), а также глобальных переменных (как статических, так и со спецификацией const). Кроме того, файл является традиционной единицей хранения в системе, а также единицей трансляции. Обычно системы хранят, транслируют и представляют пользователю программу на С++ как множество файлов, хотя существуют системы, устроенные иначе. В этой главе будет обсуждаться в основном традиционное использование файлов.

 

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

 

Вернемся к примеру с калькулятором. Решение было дано в виде одного файла. Когда вы попытаетесь его транслировать, неизбежно возникнут некоторые проблемы с порядком описаний. По крайней мере одно "ненастоящее" описание придется добавить к тексту, чтобы транслятор мог разобраться в использующих друг друга функциях expr(), term() и prim(). По тексту программы видно, что она состоит из четырех частей: лексический анализатор (сканер), собственно анализатор, таблица имен и драйвер. Однако, этот факт никак не отражен в самой программе. На самом деле калькулятор не был запрограммирован именно так. Так не следует писать программу. Даже если не учитывать все рекомендации по программированию, сопровождению и оптимизации для такой "зряшной" программы, все равно ее следует создавать из нескольких файлов хотя бы для удобства.

 

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

 

 

4.2 Связывание

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

 

// file1.c

 

int a = 1;

int f() { /* какие-то операторы */ }

 

// file2.c extern int a; int f();

void g() { a = f(); }

 

В функции g() используются те самые a и f(), которые определены в файле 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 описано дважды, но неопределено. Такие ошибки (ошибки связывания) транслятор, который обрабатывает файлы по отдельности, обнаружить не может, но большая их часть обнаруживается редактором связей.

 

Следующая программа допустима в С, но не в С++:

 

// file1.c: int a;

int f() { return a; }

 

// file2.c: int a;

int g() { return f(); }

 

Во-первых, ошибкой является вызов f() в file2.c, поскольку в этом файле f() не описана. Во-вторых, файлы программы не могут быть правильно связаны, поскольку a определено дважды.

 

Если имя описано как static, оно становится локальном в этом файле. Например:

 

// file1.c: static int a = 6;

static int f() { /* ... */ }

 

// file2.c: static int a = 7;

static int f() { /* ... */ }

 

Приведенная программа правильна, поскольку a и f определены как статические. В каждом файле своя переменная a и функция f().

 

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

 

Обычно говорят, что имена типов, т.е. классов и перечислений, не подлежат связыванию. Имена

 

глобальных классов и перечислений должны быть уникальными во всей программе и иметь единственное определение. Поэтому, если есть два даже идентичных определения одного класса, это - все равно ошибка:

 

// file1.c:

struct S { int a; char b; }; extern void f(S*);

 

// file2.c:

struct S { int a; char b; }; void f(S* p) { /* ... */ }

 

Но будьте осторожны: опознать идентичность двух описаний класса не в состоянии большинство систем программирования С++. Такое дублирование может вызвать довольно тонкие ошибки (ведь классы в разных файлах будут считаться различными).

 

Глобальные функции-подстановки подлежат внутреннему связыванию, и то же по умолчанию справедливо для констант. Синонимы типов, т.е. имена typedef, локальны в своем файле, поэтому описания в двух данных ниже файлах не противоречат друг другу:

 

// file1.c: typedef int T; const int a = 7;

inline T f(int i) { return i+a; }

 

// file2.c: typedef void T; const int a = 8;

inline T f(double d) { cout<<d; }

 

Константа может получить внешнее связывание только с помощью явного описания:

 

// file3.c:

extern const int a; const int a = 77;

 

// file4.c:

extern const int a;

void g() { cout<<a; }

 

В этом примере g() напечатает 77.

 

 

4.3 Заголовочные файлы

Типы одного объекта или функции должны быть согласованы во всех их описаниях. Должен быть согласован по типам и входной текст, обрабатываемый транслятором, и связываемые части программы. Есть простой, хотя и несовершенный, способ добиться согласованности описаний в различных файлах. Это: включить во входные файлы, содержащие операторы и определения данных, заголовочные файлы, которые содержат интерфейсную информацию.

 

Средством включения текстов служит макрокоманда #include, которая позволяет собрать в один файл

(единицу трансляции) несколько исходных файлов программы. Команда

 

#include "включаемый-файл"

 

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

 

CC -E file.c

 

Эта команда для обработки файла file.c запускает препроцессор (и только!), подобно тому, как команда

CC без флага -E запускает сам транслятор.

 

Для включения файлов из стандартных каталогов (обычно каталоги с именем INCLUDE) надо вместо кавычек использовать угловые скобки < и >. Например:

 

#include <stream.h> // включение из стандартного каталога

#include "myheader.h" // включение из текущего каталога

 

Включение из стандартных каталогов имеет то преимущество, что имена этих каталогов никак не связаны с конкретной программой (обычно вначале включаемые файлы ищутся в каталоге

/usr/include/CC, а затем в /usr/include). К сожалению, в этой команде пробелы существенны:

 

#include < stream.h> // <stream.h> не будет найден

 

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

 

Укажем, что может содержать заголовочный файл:

 

Определения типов struct point { int x, y; };

Шаблоны типов template<class T>

class V { /* ... */ }

Описания функций extern int strlen(const char*);

Определения inline char get() { return *p++; }

функций-подстановок

Описания данных extern int a;

Определения констант const float pi = 3.141593;

Перечисления enum bool { false, true };

Описания имен class Matrix;

Команды включения файлов #include <signal.h>

Макроопределения #define Case break;case

Комментарии /* проверка на конец файла */

 

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

 

Определений обычных функций char get() { return *p++; }

Определений данных int a;

Определений составных констант const tb[i] = { /* ... */ };

 

По традиции заголовочные файлы имеют расширение .h, а файлы, содержащие определения функций или данных, расширение .c. Иногда их называют "h-файлы" или "с-файлы" соответственно. Используют и другие расширения для этих файлов: .C, cxx, .cpp и .cc. Принятое расширение вы найдете в своем справочном руководстве. Макросредства описываются в $$4.7. Отметим только, что в С++ они используются не столь широко, как в С, поскольку С++ имеет определенные возможности в самом языке: определения констант (const), функций-подстановок (inline), дающие возможность более простой операции вызова, и шаблонов типа, позволяющие порождать семейство типов и функций ($$8).

 

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

 

 

4.3.1 Единственный заголовочный файл

Проще всего разбить программу на несколько файлов следующим образом: поместить определения

 

всех функций и данных в некоторое число входных файлов, а все типы, необходимые для связи между ними, описать в единственном заголовочном файле. Все входные файлы будут включать заголовочный файл. Программу калькулятора можно разбить на четыре входных файла .c: lex.c, syn.c, table.c и main.c. Заголовочный файл dc.h будет содержать описания каждого имени, которое используется более чем в одном .c файле:

 

// dc.h: общее описание для калькулятора

#include <iostream.h> enum token_value {

NAME, NUMBER, END,

PLUS='+', MINUS='-', MUL='*', DIV='/',

PRINT=';', ASSIGN='=', LP='(', RP=')'

};

extern int no_of_errors;

extern double error(const char* s); extern token_value get_token(); extern token_value curr_tok;

extern double number_value;

extern char name_string[256]; extern double expr();

extern double term(); extern double prim(); struct name {

char* string;

name* next; double value;

};

extern name* look(const char* p, int ins = 0);

inline name* insert(const char* s) { return look(s,1); }

 

Если не приводить сами операторы, lex.c должен иметь такой вид:

 

// lex.c: ввод и лексический анализ

#include "dc.h"

#include <ctype.h> token_value curr_tok; double number_value; char name_string[256];

token_value get_token() { /* ... */ }

 

Используя составленный заголовочный файл, мы добьемся, что описание каждого объекта, введенного пользователем, обязательно окажется в том файле, где этот объект определяется. Действительно, при обработке файла lex.c транслятор столкнется с описаниями

 

extern token_value get_token();

// ...

token_value get_token() { /* ... */ }

 

Это позволит транслятору обнаружить любое расхождение в типах, указанных при описании данного имени. Например, если бы функция get_token() была описана с типом token_value, но определена с типом int, трансляция файла lex.c выявила бы ошибку: несоответствие типа.

 

Файл syn.c может иметь такой вид:

 

// syn.c: синтаксический анализ и вычисления

 

#include "dc.h"

 

double prim() { /* ... */ }

double term() { /* ... */ }

double expr() { /* ... */ }

 

Файл table.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 int TBLSZ = 23; name* table[TBLSZ];

name* look(char* p, int ins) { /* ... */ }

 

Отметим, что раз строковые функции описаны в самом файле table.c, транслятор не может проверить согласованность этих описаний по типам. Всегда лучше включить соответствующий заголовочный файл, чем описывать в файле .c некоторое имя как extern. Это может привести к включению "слишком многого", но такое включение нестрашно, поскольку не влияет на скорость выполнения программы и ее размер, а программисту позволяет сэкономить время. Допустим, функция strlen() снова описывается в приведенном ниже файле main.c. Это только лишний ввод символов и потенциальный источник ошибок, т.к. транслятор не сможет обнаружить расхождения в двух описаниях strlen() (впрочем, это может сделать редактор связей). Такой проблемы не возникло бы, если бы в файле dc.h содержались все описания extern, как первоначально и предполагалось. Подобная небрежность присутствует в нашем примере, поскольку она типична для программ на С. Она очень естественна для программиста, но часто приводит к ошибкам и таким программам, которые трудно сопровождать. Итак, предупреждение сделано!

 

Наконец, приведем файл main.c:

 

// main.c: инициализация, основной цикл, обработка ошибок

#include "dc.h"

double error(char* s) { /* ... */ } extern int strlen(const char*);

int main(int argc, char* argv[]) { /* ... */ }

 

В одном важном случае заголовочные файлы вызывают большое неудобство. С помощью серии заголовочных файлов и стандартной библиотеки расширяют возможности языка, вводя множество типов (как общих, так и рассчитанных на конкретные приложения; см. главы 5-9). В таком случае текст каждой единицы трансляции может начинаться тысячами строк заголовочных файлов. Содержимое заголовочных файлов библиотеки, как правило, стабильно и меняется редко. Здесь очень пригодился бы претранслятор, который обрабатывает его. По сути, нужен язык специального назначения со своим транслятором. Но устоявшихся методов построения такого претранслятора пока нет.

 

 

4.3.2 Множественные заголовочные файлы

Разбиение программы в расчете на один заголовочный файл больше подходит для небольших программ, отдельные части которых не имеют самостоятельного назначения. Для таких программ допустимо, что по заголовочному файлу нельзя определить, чьи описания там находятся и по какой причине. Здесь могут помочь только комментарии. Возможно альтернативное решение: пусть каждая часть программы имеет свой заголовочный файл, в котором определяются средства, предоставляемые другим частям. Теперь для каждого файла .c будет свой файл .h, определяющий, что может предоставить первый. Каждый файл .c будет включать как свой файл .h, так и некоторые другие файлы

.h, исходя из своих потребностей.

 

Попробуем использовать такую организацию программы для калькулятора. Заметим, что функция error() нужна практически во всех функциях программы, а сама использует только <iostream.h>. Такая ситуация типична для функций, обрабатывающих ошибки. Следует отделить ее от файла main.c:

 

// error.h: обработка ошибок

extern int no_of_errors;

extern double error(const char* s);

 

// error.c

#include <iostream.h>

#include "error.h" int no_of_errors;

 

double error(const char* s) { /* ... */ }

 

При таком подходе к разбиению программы каждую пару файлов .c и .h можно рассматривать как модуль, в котором файл .h задает его интерфейс, а файл .c определяет его реализацию.

 

Таблица имен не зависит ни от каких частей калькулятора, кроме части обработки ошибок. Теперь этот факт можно выразить явно:

 

// table.h: описание таблицы имен

struct name { char* string; name* next; double value;

};

extern name* look(const char* p, int ins = 0);

inline name* insert(const char* s) { return look(s,1); }

 

// table.h: определение таблицы имен

#include "error.h"

#include <string.h>

#include "table.h" const int TBLSZ = 23; name* table[TBLSZ];

name* look(const char* p, int ins) { /* ... */ }

 

Заметьте, что теперь описания строковых функций берутся из включаемого файла <string.h>. Тем самым удален еще один источник ошибок.

 

// lex.h: описания для ввода и лексического анализа

NAME, NUMBER, END,  
PLUS='+', MINUS='-', MUL='*',  
PRINT=';', ASSIGN='=', LP='(', RP= ')'

 

enum token_value {

 

};

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 <iostream.h>

#include <ctype.h>

#include "error.h"

#include "lex.h" token_value curr_tok; double number_value; char name_string[256];

token_value get_token() { /* ... */ }

 

Интерфейс с синтаксическим анализатором определен четко:

 

// syn.h: описания для синтаксического анализа и вычислений

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() { /* ... */ }

 

Как обычно, определение основной программы тривиально:

 

// main.c: основная программа

#include <iostream.h>

#include "error.h"

#include "lex.h"

#include "syn.h"

#include "table.h"

int main(int argc, char* argv[]) { /* ... */ }

 

Какое число заголовочных файлов следует использовать для данной программы зависит от многих факторов. Большинство их определяется способом обработки файлов именно в вашей системе, а не собственно в С++. Например, если ваш редактор не может работать одновременно с несколькими файлами, диалоговая обработка нескольких заголовочных файлов затрудняется. Другой пример: может оказаться, что открытие и чтение 10 файлов по 50 строк каждый занимает существенно больше времени, чем открытие и чтение одного файла из 500 строк. В результате придется хорошенько подумать, прежде чем разбивать небольшую программу, используя множественные заголовочные файлы. Предостережение: обычно можно управиться с множеством, состоящим примерно из 10 заголовочных файлов (плюс стандартные заголовочные файлы). Если же вы будете разбивать программу на минимальные логические единицы с заголовочными файлами (например, создавая для каждой структуры свой заголовочный файл), то можете очень легко получить неуправляемое множество из сотен заголовочных файлов.

 

 

4.4 Связывание с программами на других языках

Программы на С++ часто содержат части, написанные на других языках, и наоборот, часто фрагмент на С++ используется в программах, написанных на других языках. Собрать в одну программу фрагменты, написанные на разных языках, или, написанные на одном языке, но в системах программирования с разными соглашениями о связывании, достаточно трудно. Например, разные языки или разные реализации одного языка могут различаться использованием регистров при передаче параметров, порядком размещения параметров в стеке, упаковкой таких встроенных типов, как целые или строки, форматом имен функций, которые транслятор передает редактору связей, объемом контроля типов, который требуется от редактора связей. Чтобы упростить задачу, можно в описании внешних указать условие связывания. Например, следующее описание объявляет strcpy внешней функцией и указывает, что она должна связываться согласно порядку связывания в С:

 

extern "C" char* strcpy(char*, const char*);

 

Результат этого описания отличается от результата обычного описания

 

extern char* strcpy(char*, const char*);

 

только порядком связывания для вызывающих strcpy() функций. Сама семантика вызова и, в частности, контроль фактических параметров будут одинаковы в обоих случаях. Описание extern "C" имеет смысл использовать еще и потому, что языки С и С++, как и их реализации, близки друг другу. Отметим, что в описании extern "C" упоминание С относится к порядку связывания, а не к языку, и часто такое описание используют для связи с Фортраном или ассемблером. Эти языки в определенной степени подчиняются порядку связывания для С.

 

Утомительно добавлять "C" ко многим описаниям внешних, и есть возможность указать такую спецификацию сразу для группы описаний. Например:

 

extern "C" {

char* strcpy(char*, const char);

int strcmp(const char*, const char*) int strlen(const char*)

 

// ...

}

 

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

 

extern "C" {

#include <string.h>

}

 

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

 

#ifdef cplusplus extern "C" {

#endif

char* strcpy(char*, const char*);

int strcmp(const char*, const char*); int strlen(const char*);

// ...

#ifdef cplusplus

}

#endif

 

Предопределенное макроопределение cplusplus нужно, чтобы обойти конструкцию extern "C" { ...},

если заголовочный файл используется для С.

 

Поскольку конструкция extern "C" { ... } влияет только на порядок связывания, в ней может содержаться любое описание, например:

 

extern "C" {

// произвольные описания

// например: static int st; int glob;

}

 

Никак не меняется класс памяти и область видимости описываемых объектов, поэтому по-прежнему st

подчиняется внутреннему связыванию, а glob остается глобальной переменной.

 

Укажем еще раз, что описание extern "C" влияет только на порядок связывания и не влияет на порядок вызова функции. В частности, функция, описанная как extern "C", все равно подчиняется правилам контроля типов и преобразования фактических параметров, которые в C++ строже, чем в С. Например:

 

extern "C" int f(); int g()

{

return f(1); // ошибка: параметров быть не должно

}

 

4.5 Как создать библиотеку

Распространены такие обороты (и в этой книге тоже): "поместить в библиотеку", "поискать в такой-то библиотеке". Что они означают для программ на С++? К сожалению, ответ зависит от используемой системы. В этом разделе говорится о том, как создать и использовать библиотеку для десятой версии системы ЮНИКС. Другие системы должны предоставлять похожие возможности. Библиотека состоит из файлов .o, которые получаются в результате трансляции файлов .c. Обычно существует один или несколько файлов .h, в которых содержатся необходимые для вызова файлов .o описания. Рассмотрим в качестве примера, как для четко не оговоренного множества пользователей можно достаточно удобно определить некоторое множество стандартных математических функций. Заголовочный файл может иметь такой вид:

 

extern "C" { // стандартные математические функции

 

// как правило написаны на С

double sqrt(double); // подмножество <math.h>

double sin(double); double cos(double); double exp(double); double log(double);

// ...

}

 

Определения этих функций будут находиться в файлах sqrt.c, sin.c, cos.c, exp.c и log.c, соответственно. Библиотеку с именем math.a можно создать с помощью таких команд:

$ 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. Наконец, для быстрого доступа к функциям архив индексируется. Если в вашей системе нет команды ranlib (возможно она и не нужна), то, по крайней мере, можно найти в справочном руководстве ссылку на имя ar. Чтобы использовать библиотеку в своей программе, надо задать режим трансляции следующим образом:

 

$ CC myprog.c math.a

 

Встает вопрос: что дает нам библиотека math.a? Ведь можно было бы непосредственно использовать файлы .o, например так:

 

$ CC myprog.c sqrt.o sin.o cos.o exp.o log.o

 

Дело в том, что во многих случаях трудно правильно указать, какие файлы .o действительно нужны. В приведенной выше команде использовались все из них. Если же в myprog вызываются только sqrt() и cos(), тогда, видимо, достаточно задать такую команду:

 

$ CC myprog.c sqrt.o cos.o

 

Но это будет неверно, т.к. функция cos() вызывает sin().

 

Редактор связей, который вызывается командой CC для обработки файлов .a (в нашем случае для файла math.a), умеет из множества файлов, образующих библиотеку, извлекать только нужные файлы

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

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

 

 

4.6 Функции

Самый распространенный способ задания в С++ каких-то действий - это вызов функции, которая выполняет такие действия. Определение функции есть описание того, как их выполнить. Неописанные функции вызывать нельзя.

 

 

4.6.1 Описания функций

Описание функции содержит ее имя, тип возвращаемого значения (если оно есть) и число и типы параметров, которые должны задаваться при вызове функции. Например:

 

extern double sqrt(double); extern elem* next_elem();

extern char* strcpy(char* to, const char* from);

extern void exit(int);

 

Семантика передачи параметров тождественна семантике инициализации: проверяются типы

 

фактических параметров и, если нужно, происходят неявные преобразования типов. Так, если учесть приведенные описания, то в следующем определении:

 

double sr2 = sqrt(2);

 

содержится правильный вызов функции sqrt() со значением с плавающей точкой 2.0. Контроль и преобразование типа фактического параметра имеет в С++ огромное значение.

 

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

 

 

4.6.2 Определения функций

Каждая вызываемая в программе функция должна быть где-то в ней определена, причем только один раз. Определение функции - это ее описание, в котором содержится тело функции. Например:

 

extern void swap(int*, int*); // описание

void swap(int* p, int* q) // определение

{

int t = *p;

*p = *q;

*q = *t;

}

 

Не так редки случаи, когда в определении функции не используются некоторые параметры:

 

void search(table* t, const char* key, const char*)

{

// третий параметр не используется

// ...

}

 

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

 

Уже говорилось, что функцию можно определить как подстановку (inline). Например:

 

inline fac(int i) { return i<2 ? 1 : n*fac(n-1); }

 

Спецификация inline служит подсказкой транслятору, что вызов функции fac можно реализовать подстановкой ее тела, а не с помощью обычного механизма вызова функций ($$R.7.1.2). Хороший оптимизирующий транслятор вместо генерации вызова fac(6) может просто использовать константу 720. Из-за наличия взаиморекурсивных вызовов функций-подстановок, а также функций-подстановок, рекурсивность которых зависит от входных данных, нельзя утверждать, что каждый вызов функции- подстановки действительно реализуется подстановкой ее тела. Степень оптимизации, проводимой транслятором, нельзя формализовать, поэтому одни трансляторы создадут команды 6*5*4*3*2*1, другие - 6*fac(5), а некоторые ограничатся неоптимизированным вызовом fac(6).

 

Чтобы реализация вызова подстановкой стала возможна даже для не слишком развитых систем программирования, нужно, чтобы не только определение, но и описание функции-подстановки находилось в текущей области видимости. В остальном спецификация inline не влияет на семантику вызова.

 

 

4.6.3 Передача параметров

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

 

стандартное значение параметра ($$4.6.7). Рассмотрим функцию:

 

void f(int val, int& ref)

{

val++; ref++;

}

 

При вызове f() в выражении val++ увеличивается локальная копия первого фактического параметра, тогда как в ref++ - сам второй фактический параметр увеличивается сам. Поэтому в функции

 

void g()

{

int i = 1; int j = 1; f(i,j);

}

 

увеличится значение j, но не i. Первый параметр i передается по значению, а второй параметр j передается по ссылке. В $$2.3.10 мы говорили, что функции, которые изменяют свой передаваемый по ссылке параметр, труднее понять, и что поэтому лучше их избегать (см. также $$10.2.2). Но большие объекты, очевидно, гораздо эффективнее передавать по ссылке, чем по значению. Правда можно описать параметр со спецификацией const, чтобы гарантировать, что передача по ссылке используется только для эффективности, и вызываемая функция не может изменить значение объекта:

 

void f(const large& arg)

{

// значение "arg" нельзя изменить без явных

// операций преобразования типа

}

 

Если в описании параметра ссылки const не указано, то это рассматривается как намерение изменять передаваемый объект:

 

void g(large& arg); // считается, что в g() arg будет меняться

 

Отсюда мораль: используйте const всюду, где возможно.

 

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

 

extern int strlen(const char*); // из <string.h> extern char* strcpy(char* to, const char* from); extern int strcmp(const char*, const char*);

 

Значение такого приема растет вместе с ростом программы.

 

Отметим, что семантика передачи параметров отличается от семантики присваивания. Это различие существенно для параметров, являющихся const или ссылкой, а также для параметров с типом, определенным пользователем ($1.4.2).

 

Литерал, константу и параметр, требующий преобразования, можно передавать как параметр типа const&, но без спецификации const передавать нельзя. Допуская преобразования для параметра типа const T&, мы гарантируем, что он может принимать значения из того же множества, что и параметр типа T, значение которого передается при необходимости с помощью временной переменной.

 

float fsqrt(const float&); // функция sqrt в стиле Фортрана

void g(double d)

{

float r;

r = fsqrt(2.0f); // передача ссылки на временную

// переменную, содержащую 2.0f

r = fsqrt(r); // передача ссылки на r

r = fsqrt(d); // передача ссылки на временную

// переменную, содержащую float(d)

 

}

 

Запрет на преобразования типа для параметров-ссылок без спецификации const введен для того, чтобы избежать нелепых ошибок, связанных с использованием при передаче параметров временных переменных:

 

void update(float& i); void g(double d)

{

float r;

update(2.0f); // ошибка: параметр-константа

update(r); // нормально: передается ссылка на r update(d); // ошибка: здесь нужно преобразовывать тип

}

 

4.6.4 Возвращаемое значение

Если функция не описана как void, она должна возвращать значение. Например:

 

int f() { } // ошибка

void g() { } // нормально

 

Возвращаемое значение указывается в операторе 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;

}

 

Подобно передаче параметров, операция возвращения значения функции эквивалентна инициализации. Считается, что оператор return инициализирует переменную, имеющую тип возвращаемого значения. Тип выражения в операторе return сверяется с типом функции, и производятся все стандартные и пользовательские преобразования типа. Например:

 

double f()

{

// ...

return 1; // неявно преобразуется в double(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[] = "массив"; strlen(v); strlen("Николай");

}

 

Это означает, что фактический параметр типа T[] преобразуется к типу T*, и затем передается. Поэтому присваивание элементу формального параметра-массива изменяет этот элемент. Иными словами, массивы отличаются от других типов тем, что они не передаются и не могут передаваться по значению.

 

В вызываемой функции размер передаваемого массива неизвестен. Это неприятно, но есть несколько способов обойти данную трудность. Прежде всего, все строки оканчиваются нулевым символом, и значит их размер легко вычислить. Можно передавать еще один параметр, задающий размер массива. Другой способ: определить структуру, содержащую указатель на массив и размер массива, и передавать ее как параметр (см. также $$1.2.5). Например:

 

void compute1(int* vec_ptr, int vec_size); // 1-ый способ

struct vec { // 2-ой способ

int* ptr; int size;

};

void compute2(vec v);

 

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

 

char* day[] = {

"mon", "tue", "wed", "thu", "fri", "sat", "sun"

};

 

Теперь рассмотрим функцию, работающую с двумерным массивом - матрицей. Если размеры обоих индексов известны на этапе трансляции, то проблем нет:

 

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';

}

}

 

Конечно, матрица по-прежнему передается как указатель, а размерностиприведены просто для полноты описания.

 

Первая размерность для вычисления адреса элемента неважна($$R.8.2.4), поэтому ее можно передавать как параметр:

 

void print_mi4(int m[][4], int dim1)

 

{

for ( int i = 0; i<dim1; i++) {

for ( int j = 0; j<4; j++) cout << ' ' << m[i][j];

cout << '\n';

}

}

 

Самый сложный случай - когда надо передавать обе размерности. Здесь "очевидное" решение просто непригодно:

 

void print_mij(int m[][], int dim1, int dim2) // ошибка

{

for ( int i = 0; i<dim1; i++) { for ( int j = 0; j<dim2; j++)

cout << ' ' << m[i][j]; cout << '\n';

}

}

 

Во-первых, описание параметра m[][] недопустимо, поскольку для вычисления адреса элемента многомерного массива нужно знать вторую размерность. Во-вторых, выражение m[i][j] вычисляется как

*(*(m+i)+j), а это, по всей видимости, не то, что имел в виду программист. Приведем правильное

решение:

 

void print_mij(int** m, int dim1, int dim2)

{

for (int i = 0; i< dim1; i++) { for (int j = 0; j<dim2; j++)

cout << ' ' << ((int*)m)[i*dim2+j]; // запутано

cout << '\n';

}

}

 

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

 

int* v = (int*)m;

// ...

v[i*dim2+j]

 

Лучше такие достаточно запутанные места в программе упрятывать. Можно определить тип многомерного массива с соответствующей операцией индексирования. Тогда пользователь может и не знать, как размещаются данные в массиве (см. упражнение 18 в $$7.13).

 

 

4.6.6 Перегрузка имени функции

Обычно имеет смысл давать разным функциям разные имена. Если же несколько функций выполняет одно и то же действие над объектами разных типов, то удобнее дать одинаковые имена всем этим функциям. Перегрузкой имени называется его использование для обозначения разных операций над разными типами. Собственно уже для основных операций С++ применяется перегрузка. Действительно: для операций сложения есть только одно имя +, но оно используется для сложения и целых чисел, и чисел с плавающей точкой, и указателей. Такой подход легко можно распространить на операции, определенные пользователем, т.е. на функции. Например:

 

void print(int); // печать целого

void print(const char*) // печать строки символов

 

Для транслятора в таких перегруженных функциях общее только одно - имя. Очевидно, по смыслу такие функции сходны, но язык не способствует и не препятствует выделению перегруженных функций. Таким образом, определение перегруженных функций служит, прежде всего, для удобства записи. Но для функций с такими традиционными именами, как sqrt, print или open, нельзя этим удобством

 

пренебрегать. Если само имя играет важную семантическую роль, например, в таких операциях, как + , * и << ($$7.2), или для конструктора класса ($$5.2.4 и $$7.3.1), то такое удобство становится существенным фактором. При вызове функции с именем f транслятор должен разобраться, какую именно функцию f следует вызывать. Для этого сравниваются типы фактических параметров, указанные в вызове, с типами формальных параметров всех описаний функций с именем f. В результате вызывается та функция, у которой формальные параметры наилучшим образом сопоставились с параметрами вызова, или выдается ошибка если такой функции не нашлось. Например:

 

void print(double); void print(long); void f()

{

print(1L); // print(long)

print(1.0); // print(double)

print(1); // ошибка, неоднозначность: что вызывать

// print(long(1)) или print(double(1)) ?

}

 

Подробно правила сопоставления параметров описаны в $$R.13.2. Здесь достаточно привести их суть. Правила применяются в следующем порядке по убыванию их приоритета:

 

[1] Точное сопоставление: сопоставление произошло без всяких преобразований типа или только с неизбежными преобразованиями (например, имени массива в указатель, имени функции в указатель на функцию и типа T в const T).

 

[2] Сопоставление с использованием стандартных целочисленных преобразований, определенных в $$R.4.1 (т.е. char в int, short в int и их беззнаковых двойников в int), а также преобразований float в double.

 

[3] Сопоставление с использованием стандартных преобразований, определенных в $$R.4 (например, int в double, derived* в base*, unsigned в int).

 

[4] Сопоставление с использованием пользовательских преобразований ($$R.12.3). [5] Сопоставление с использованием эллипсиса ... в описании функции.

Если найдены два сопоставления по самому приоритетному правилу, то вызов считается неоднозначным, а значит ошибочным. Эти правила сопоставления параметров работают с учетом

правил преобразований числовых типов для С и С++. Пусть имеются такие описания функции print:

 

void print(int);

void print(const char*); void print(double);

void print(long); void print(char);

 

Тогда результаты следующих вызовов print() будут такими:

 

void h(char c, int i, short s, float f)

{

print(c); // точное сопоставление: вызывается print(char)

print(i); // точное сопоставление: вызывается print(int)

print(s); // стандартное целочисленное преобразование:

// вызывается print(int)

print(f); // стандартное преобразование:

// вызывается print(double)

print('a'); // точное сопоставление: вызывается print(char)

print(49); // точное сопоставление: вызывается print(int) print(0); // точное сопоставление: вызывается print(int) print("a"); // точное сопоставление:

// вызывается print(const char*)

}

 

Обращение print(0) приводит к вызову print(int), ведь 0 имеет тип int. Обращение print('a') приводит к вызову print(char), т.к. 'a' - типа char ($$R.2.5.2).

 

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

 

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

 

int pow(int, int);

double pow(double, double); // из <math.h> complex pow(double, complex); // из <complex.h> complex pow(complex, int);

complex pow(complex, double); complex pow(complex, complex); void k(complex z)

{

int i = pow(2,2); // вызывается pow(int,int) double d = pow(2.0,2); // вызывается pow(double,double)

complex z2 = pow(2,z); // вызывается pow(double,complex) complex z3 = pow(z,2); // вызывается pow(complex,int) complex z4 = pow(z,z); // вызывается pow(complex,complex)

}

 

4.6.7 Стандартные значения параметров

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

 

void print (int value, int base =10); void F()

{

print(31); print(31,10);

print(31,16);

print(31,2);

}

 

напечатает такие числа:

 

31 31 1f 11111

 

Вместо стандартного значения параметра можно было бы использовать перегрузку функции print:

 

void print(int value, int base);

inline void print(int value) { print(value,10); }

 

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

 

Тип стандартного параметра сверяется с типом указанного значения при трансляции описания функции, а значение этого параметра вычисляется в момент вызова функции. Задавать стандартное значение можно только для завершающих подряд идущих параметров:

 

int f(int, int =0, char* =0); // нормально int g(int =0, int =0, char*); // ошибка int h(int =0, int, char* =0); // ошибка

 

Отметим, что в данном контексте наличие пробела между символами * и = весьма существенно, поскольку *= является операцией присваивания:

 

int nasty(char*=0); // синтаксическая ошибка

 

 

4.6.8 Неопределенное число параметров

Существуют функции, в описании которых невозможно указать число и типы всех допустимых параметров. Тогда список формальных параметров завершается эллипсисом (...), что означает: "и, возможно, еще несколько аргументов". Например:

 

int printf(const char* ...);

 

При вызове printf обязательно должен быть указан параметр типа char*, однако могут быть (а могут и не быть) еще другие параметры. Например:

 

printf("Hello, world\n");

printf("My name is %s %s\n", first_name, second_name); printf("%d + %d = %d\n", 2,3,5);

 

Такие функции пользуются для распознавания своих фактических параметров недоступной транслятору информацией. В случае функции printf первый параметр является строкой, специфицирующей формат вывода. Она может содержать специальные символы, которые позволяют правильно воспринять последующие параметры. Например, %s означает -"будет фактический параметр типа char*", %d означает -"будет фактический параметр типа int" (см. $$10.6). Но транслятор этого не знает, и поэтому он не может убедиться, что объявленные параметры действительно присутствуют в вызове и имеют соответствующие типы. Например, следующий вызов

 

printf("My name is %s %s\n",2);

 

нормально транслируется, но приведет (в лучшем случае) к неожиданной выдаче. Можете проверить сами.

 

Очевидно, что раз параметр неописан, то транслятор не имеет сведений для контроля и стандартных преобразований типа этого параметра. Поэтому char или short передаются как int, а float как double, хотя пользователь, возможно, имел в виду другое.

 

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

 

extern "C" int fprintf(FILE*, const char* ...); extern "C" int execl(const char* ...);

 

Есть стандартный набор макроопределений, находящийся в <stdarg.h>, для выбора незаданных параметров этих функций. Рассмотрим функцию реакции на ошибку, первый параметр которой показывает степень тяжести ошибки. За ним может следовать произвольное число строк. Нужно составить сообщение об ошибке с учетом, что каждое слово из него передается как отдельная строка:

 

extern void error(int ...) extern char* itoa(int); main(int argc, char* argv[])

{

switch (argc) { case 1:

error(0,argv[0],(char*)0); break;

case 2:

error(0,argv[0],argv[1],(char*)0); break;

default:

 

error(1,argv[0],

"With",itoa(argc-1),"arguments",(char*)0);

}

// ...

}

 

Функция itoa возвращает строку символов, представляющую ее целый параметр. Функцию реакции на ошибку можно определить так:

 

#include <stdarg.h>

void error(int severity ...)

/*

за "severity" (степень тяжести ошибки) следует список строк, завершающийся нулем

*/

{

va_list ap;

va_start(ap,severity); // начало параметров

for (;;) {

char* p = va_arg(ap,char*); if (p == 0) break;

cerr << p << ' ';

}

va_end(ap); // очистка параметров

cerr << '\n';

if (severity) exit(severity);

}

 

Вначале при вызове va_start() определяется и инициализируется va_list. Параметрами макроопределения va_start являются имя типа va_list и последний формальный параметр. Для выборки по порядку неописанных параметров используется макроопределение va_arg(). В каждом обращении к va_arg нужно задавать тип ожидаемого фактического параметра. В va_arg() предполагается, что параметр такого типа присутствует в вызове, но обычно нет возможности проверить это. Перед выходом из функции, в которой было обращение к va_start, необходимо вызвать va_end. Причина в том, что в va_start() могут быть такие операции со стеком, из-за которых корректный возврат из функции становится невозможным. В va_end() устраняются все нежелательные изменения стека.

 

Приведение 0 к (char*)0 необходимо потому, что sizeof(int) не обязано совпадать с sizeof(char*). Этот пример демонстрирует все те сложности, с которыми приходится сталкиваться программисту, если он решил обойти контроль типов, используя эллипсис.

 

 

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")), что является ошибкой. По той же причине скобки нужны и при описании указателя на функцию. Однако, писать просто efct("error") можно, т.к. транслятор понимает, что efct является указателем на функцию, и создает команды, делающие вызов нужной функции.

 

Отметим, что формальные параметры в указателях на функцию описываются так же, как и в обычных

 

функциях. При присваивании указателю на функцию требуется точное соответствие типа функции и типа присваиваемого значения. Например:

 

void (*pf)(char*); // указатель на void(char*)

void f1(char*); // void(char*);

int f2(char*); // int(char*);

void f3(int*); // void(int*); void f()

{

pf = &f1; // нормально

pf = &f2; // ошибка: не тот тип возвращаемого

// значения

pf = &f3; // ошибка: не тот тип параметра

(*pf)("asdf"); // нормально

(*pf)(1); // ошибка: не тот тип параметра

int i = (*pf)("qwer"); // ошибка: void присваивается int

}

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

достаточно сложную запись. Например:

 

typedef int (*SIG_TYP)(int); // из <signal.h> typedef void (SIG_ARG_TYP)(int);

SIG_TYP signal(int, SIG_ARG_TYP);

 

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

 

typedef void (*PF)();

PF edit_ops[] = { // команды редактора

&cut, &paste, &snarf, &search

};

PF file_ops[] = { // управление файлом

&open, &reshape, &close, &write

};

 

Далее надо определить и инициализировать указатели, с помощью которых будут запускаться функции, реализующие выбранные из меню команды. Выбор происходит нажатием клавиши мыши:

 

PF* button2 = edit_ops; PF* button3 = file_ops;

 

Для настоящей программы редактора надо определить большее число объектов, чтобы описать каждую позицию в меню. Например, необходимо где-то хранить строку, задающую текст, который будет выдаваться для каждой позиции. При работе с системой меню назначение клавиш мыши будет постоянно меняться. Частично эти изменения можно представить как изменения значений указателя, связанного с данной клавишей. Если пользователь выбрал позицию меню, которая определяется, например, как позиция 3 для клавиши 2, то соответствующая команда реализуется вызовом:

(*button2[3])();

 

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

 

Довольно просто создавать в динамике и новые меню.

 

Указатели на функции помогают реализовать полиморфические подпрограммы, т.е. такие подпрограммы, которые можно применять к объектам различных типов:

typedef int (*CFT)(void*,void*);

void sort(void* base, unsigned n, unsigned int sz, CFT cmp)

 

/*

Сортировка вектора "base" из n элементов

в возрастающем порядке;

используется функция сравнения, на которую указывает cmp.

Размер элементов равен "sz".

Алгоритм очень неэффективный: сортировка пузырьковым методом

*/

{

for (int i=0; i<n-1; i++)

for (int j=n-1; i<j; j--) {

char* pj = (char*)base+j*sz; // b[j] char* pj1 = pj - sz; // b[j-1] if ((*cmp)(pj,pj1) < 0) {

// поменять местами b[j] и b[j-1]

for (int k = 0; k<sz; k++) { char temp = pj[k];

pj[k] = pj1[k]; pj1[k] = temp;

}

}

}

}

 

В подпрограмме sort неизвестен тип сортируемых объектов; известно только их число (размер массива), размер каждого элемента и функция, которая может сравнивать объекты. Мы выбрали для функции sort() такой же заголовок, как у qsort() - стандартной функции сортировки из библиотеки С. Эту функцию используют настоящие программы. Покажем, как с помощью sort() можно отсортировать таблицу с такой структурой:

 

struct user {

char* name; // имя

char* id; // пароль

int dept; // отдел

};

typedef user* Puser; user heads[] = {

"Ritchie D.M.", "dmr", 11271,

"Sethi R.", "ravi", 11272,

"SZYmanski T.G.", "tgs", 11273,

"Schryer N.L.", "nls", 11274,

"Schryer N.L.", "nls", 11275 "Kernighan B.W.", "bwk", 11276

};

void print_id(Puser v, int n)

{

for (int i=0; i<n; i++) cout << v[i].name << '\t'

<< v[i].id << '\t'

<< v[i].dept << '\n';

}

 

Чтобы иметь возможность сортировать, нужно вначале определить подходящие функции сравнения. Функция сравнения должна возвращать отрицательное число, если ее первый параметр меньше второго, нуль, если они равны, и положительное число в противном случае:

 

int cmp1(const void* p, const void* q)

// сравнение строк, содержащих имена

{

return strcmp(Puser(p)->name, Puser(q)->name);

}

int cmp2(const void* p, const void* q)

 

// сравнение номеров разделов

{

return Puser(p)->dept - Puser(q)->dept;

}

 

Следующая программа сортирует и печатает результат:

 

int main()

{

sort(heads,6,sizeof(user), cmp1); print_id(heads,6); // в алфавитном порядке cout << "\n"; sort(heads,6,sizeof(user),cmp2); print_id(heads,6); // по номерам отделов

}

 

Допустима операция взятия адреса и для функции-подстановки, и для перегруженной функции

($$R.13.3).

 

Отметим, что неявное преобразование указателя на что-то в указатель типа void* не выполняется для параметра функции, вызываемой через указатель на нее. Поэтому функцию

 

int cmp3(const mytype*, const mytype*);

 

нельзя использовать в качестве параметра для sort(). Поступив иначе, мы нарушаем заданное в описании условие, что cmp3() должна вызываться с параметрами типа mytype*. Если вы специально хотите нарушить это условие, то должны использовать явное преобразование типа.

 

 

4.7 Макросредства

Макросредства языка определяются в $$R.16. В С++ они играют гораздо меньшую роль, чем в С. Можно даже дать такой совет: используйте макроопределения только тогда, когда не можете без них обойтись. Вообще говоря, считается, что практически каждое появление макроимени является свидетельством некоторых недостатков языка, программы или программиста. Макросредства создают определенные трудности для работы служебных системных программ, поскольку они перерабатывают программный текст еще до трансляции. Поэтому, если ваша программа использует макросредства, то сервис, предоставляемый такими программами, как отладчик, профилировщик, программа перекрестных ссылок, будет для нее неполным. Если все-таки вы решите использовать макрокоманды, то вначале тщательно изучите описание препроцессора С++ в вашем справочном руководстве и не старайтесь быть слишком умным.

 

Простое макроопределение имеет вид:

 

#define имя остаток-строки

 

В тексте программы лексема имя заменяется на остаток-строки. Например,

 

объект = имя

 

будет заменено на

 

объект = остаток-строки

 

Макроопределение может иметь параметры. Например:

 

#define mac(a,b) argument1: a argument2: b

 

В макровызове mac должны быть заданы две строки, представляющие параметры. При подстановке они заменят a и b в макроопределении mac(). Поэтому строка

 

expanded = mac(foo bar, yuk yuk)

 

при подстановке преобразуется в

 

expanded = argument1: foo bar argument2: yuk yuk

 

Макроимена нельзя перегружать. Рекурсивные макровызовы ставят перед препроцессором слишком

 

сложную задачу:

 

// ошибка:

#define print(a,b) cout<<(a)<<(b)

#define print(a,b,c) cout<<(a)<<(b)<<(c)

 

// слишком сложно:

#define fac(n) (n>1) ?n*fac(n-1) :1

 

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

 

Допустимы такие макроопределения:

 

#define Case break;case

#define forever for(;;)

 

А вот совершенно излишние макроопределения:

 

#define PI 3.141593

#define BEGIN {

#define END }

 

Следующие макроопределения могут привести к ошибкам:

 

#define SQUARE(a) a*a

#define INCR_xx (xx)++

#define DISP = 4

 

Чтобы убедиться в этом, достаточно попробовать сделать подстановку в таком примере:

 

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), и всюду, где это возможно, заключайте имя параметра макроопределения в скобки. Например:

 

#define MIN(a,b) (((a)<(b))?(a):(b))

 

Если макроопределение достаточно сложное, и требуется комментарий к нему, то разумнее написать комментарий вида /* */, поскольку в реализации С++ может использоваться препроцессор С, который не распознает комментарии вида //. Например:

 

#define m2(a) something(a) /* глубокомысленный комментарий */

 

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

 

const int answer = 42; template<class T>

inline T min(T a, T b) { return (a<b)?a:b; }

 

4.8 Упражнения

1. (*1) Составьте следующие описания: функция с параметрами типа указатель на символ и ссылка на целое, невозвращающая значения; указатель на такую функцию; функция с параметром, имеющим тип такого указателя; функция, возвращающая такой указатель. Напишите определение функции, у которой параметр и возвращаемое значение имеют тип такого указателя. Подсказка: используйте typedef.

 

2. (*1) Как понимать следующее описание? Где оно может пригодиться?

 

typedef int (rifii&) (int, int);

 

3. (*1.5) Напишите программу, подобную той, что выдает "Hello, world". Она получает имя (name) как параметр командной строки и выдает "Hello, name". Измените программу так, чтобы она получала произвольное число имен и всем им выдавала свое приветствие: "Hello, ...".

 

4. (1.5) Напишите программу, которая, беря из командной строки произвольное число имен файлов, все эти файлы переписывает один за другим в cout. Поскольку в программе происходит конкатенация файлов, вы можете назвать ее cat от слова concatenation - конкатенация).

 

5. (*2) Переведите небольшую программу с языка С на С++. Измените заголовочные файлы так, чтобы они содержали описание всех вызываемых функций и описание типов всех параметров. По возможности все команды #define замените конструкциями enum, const или inline. Удалите из файлов .c все описания внешних, а определения функций приведите к виду, соответствующему С++. Вызовы malloc() и free() замените операциями new и delete. Удалите ненужные операции приведения.

 

6. (*2) Напишите функцию sort() ($$4.6.9), использующую более эффективный алгоритм сортировки.

 

7. (*2) Посмотрите на определение структуры tnode в $$R.9.3. Напишите функцию, заносящую новые слова в дерево узлов tnode. Напишите функцию для вывода узлов дерева tnode. Напишите функцию, которая производит такой вывод в алфавитном порядке. Измените структуру tnode так, чтобы в ней содержался только указатель на слово произвольной длины, которое размещается с помощью new в свободной памяти. Измените функцию так, чтобы она работала с новой структурой tnode.

 

8. (*1) Напишите функцию itoa(), которая использовалась в примере из $$4.6.8.

 

9. (*2) Узнайте, какие стандартные заголовочные файлы есть в вашей системе. Поройтесь в каталогах

/usr/include или /usr/include/CC (или в тех каталогах, где хранятся стандартные заголовочные файлы вашей системы). Прочитайте любой показавшийся интересным файл.

 

10. (*2) Напишите функцию, которая будет переворачивать двумерный массив. (Первый элемент массива станет последним).

 

11. (*2) Напишите шифрующую программу, которая читает символы из cin и пишет их в cout в зашифрованном виде. Можно использовать следующий простой метод шифрации: для символа s зашифрованное представление получается в результате операции s^key[i], где key - массив символов, передаваемый в командной строке. Символы из массива key используются в циклическом порядке, пока не будет прочитан весь входной поток. Первоначальный текст получается повторным применением той же операции с теми же элементами key. Если массив key не задан (или задана пустая строка), шифрация не происходит.

 

12. (*3) Напишите программу, которая помогает дешифрировать текст, зашифрованный описанным выше способом, когда ключ (т.е. массив key) неизвестен. Подсказка: см. D Kahn "The Codebreakers", Macmillan, 1967, New York, стр. 207-213.

 

13. (*3) Напишите функцию обработки ошибок, первый параметр который подобен форматирующей строке-параметру printf() и содержит форматы %s, %c и %d. За ним может следовать произвольное количество числовых параметров. Функцию printf() не используйте. Если смысл формата %s и других форматов вам неизвестен, обратитесь к $$10.6. Используйте <stdarg.h>.

 

14. (*1) Какое имя вы выбрали бы для типов указателей на функции, которые определяются с помощью

typedef?

 

15. (*2) Исследуйте разные программы, чтобы получить представление о разных используемых на практике стилях именования. Как используются заглавные буквы? Как используется подчерк? В каких случаях используются такие имена, как i или x?

 

16. (*1) Какие ошибки содержатся в следующих макроопределениях?

 

#define PI = 3.141593;

#define MAX(a,b) a>b?a:b

#define fac(a) (a)*fac((a)-1)

 

17. (*3) Напишите макропроцессор с простыми возможностями, как у препроцессора С. Текст читайте из cin, а результат записывайте в cout. Вначале реализуйте макроопределения без параметров. Подсказка: в программе калькулятора есть таблица имен и синтаксический анализатор, которыми можно воспользоваться.

 

18. (*2) Напишите программу, извлекающую квадратный корень из двух (2) с помощью стандартной функции sqrt(), но не включайте в программу <math.h>. Сделайте это упражнение с помощью функции sqrt() на Фортране.

 

19. (*2) Реализуйте функцию print() из $$4.6.7.

 

5. КЛАССЫ

 

 

"Эти типы не абстрактные, они столь же реальны, как int и float"

- Даг Макилрой

 

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

 

5.1 Введение и краткий обзор

Понятие класса, которому посвящена эта и три следующих главы, служит в С++ для того, чтобы дать программисту инструмент построения новых типов. Ими пользоваться не менее удобно, чем встроенными. В идеале использование определенного пользователем типа не должно отличаться от использования встроенных типов. Различия возможны только в способе построения.

 

Тип есть вполне конкретное представление некоторого понятия. Например, в С++ тип float с операциями

+, -, * и т.д. является хотя и ограниченным, но конкретным представлением математического понятия вещественного числа. Новый тип создается для того, чтобы стать специальным и конкретным представлением понятия, которое не находит прямого и естественного отражения среди встроенных типов. Например, в программе из области телефонной связи можно ввести тип trunk_module (линия- связи), в видеоигре - тип explosion (взрыв), а в программе, обрабатывающей текст, - тип list_of_paragraphs (список-параграфов). Обычно проще понимать и изменять программу, в которой типы хорошо представляют используемые в задаче понятия. Удачно подобранное множество пользовательских типов делает программу более ясной. Оно позволяет транслятору обнаруживать недопустимое использование объектов, которое в противном случае останется невыявленным до отладки программы.

 

Главное в определении нового типа - это отделить несущественные детали реализации (например, расположение данных в объекте нового типа) от тех его характеристик, которые существенны для правильного его использования (например, полный список функций, имеющих доступ к данным). Такое разделение обеспечивается тем, что вся работа со структурой данных и внутрение, служебные операции над нею доступны только через специальный интерфейс (через "одно горло").

 

Глава состоит из четырех частей: