4301

Язык СИ++ Учебное пособие

Книга

Информатика, кибернетика и программирование

Предисловие Язык программирования Си++ был разработан на основе языка Си Бьярном Страуструпом и вышел за пределы его исследовательской группы в начале 80-х годов. На первых этапах разработки (1980 г.) язык носил условное назв...

Русский

2012-11-16

2.73 MB

485 чел.

Предисловие

Язык программирования Си++ был разработан на основе языка Си Бьярном Страуструпом (Вjаrnе Stroustгuр) и вышел за пределы его исследовательской группы в начале 80-х годов [I]. На первых этапах разработки (1980 г.) язык носил условное название "Си с классами", а в 1983г. Рик Масситти придумал название "Си++", что образно отразило происхождение этого нового языка от языка Си. Язык Си++ является расширением (надмножеством) языка Си, поэтому программы, написанные на Си, могут обрабатываться компилятором языка Си++. Более того, в программах на языке Си++ можно использовать тексты на языке Си и обращаться к библиотечным функциям языка Си. Таким образом, одно из достоинств Си++ состоит в возможности использовать уже существующие программы на Си. Однако это не единственное достоинство языка. Как пишет его автор [2], язык Си++ был создан с учетом следующих целей: улучшить язык Си, поддержать абстракцию данных и обеспечить объектно-ориентированное программирование.

Настоящая работа посвящена синтаксису, семантике, технике и стилю построения программ языка Си++. Фундаментальным трудом по синтаксису и семантике языка Си++ является справочное руководство по языку (Проект стандарта АN51) М. Эллис и Б. Страуструпа Как всякий стандарт, работа [2] непригодна для первоначального знакомства с языком, что и отмечают в предисловии к русскому изданию сами авторы [2]: "... книга не предназначена для обучения технике и стилю программирования". В настоящее время на русском языке издано несколько хороших пособий по языку Си++ для тех, кто уже программирует на языке Си (см. библиографию). В отличие от этих пособий, в которых язык Си++ вводится как расширение своего предшественника Си, настоящая работа не требует от читателя знакомства с языком Си. Другими словами, материал книги по возможности "замкнут" и содержит всю необходимую информацию для фундаментального освоения языка Си++ как нового для читателя программирования. Например, книгу может изучать читатель, знакомый с языком Паскаль или Бейсик, или Фортран, или ПЛ/1 и т.п. Знакомство с языком Си никак не повредит читателю, но изложение материала этого не предполагает. Итак, язык Си++ излагается как новый язык, свойства которого раскрываются перед читателем постепенно. Здесь мы полностью следуем совету: "... программируя на Си++, нужно... не применять те средства языка, которые представляются вам неясными, как и те, с которыми вы еще не успели ознакомиться" [2].Несмотря на широкое распространение и популярность языка Си++ стандарты для него отсутствуют. Через несколько лет его практического использования стандартом де-факто стала спецификация языка. АТ&Т С++ ге1еаsе 2.0, разработанная в Ве11 laboratories фирмы AТ&Т под руководством автора языка Б. Страуструпа. Затем там же появилась усовершенствованная версия 3.0 языка Си++. В настоящее время в Американском национальном институте Стандартов (АNS1) существует комитет по языку Си++ (ХЗJIб). Изданное в 1990 году описание языка с комментариями [2] принято комитетом АNS1 в качестве исходного материала для стандартизации Си++. С тех пор рабочей группой WG21 комитета АN51 выпущено несколько предварительных версий стандарта. Последняя из них (но не окончательная) [34] датирована 26 мая 1994 г. (документ АNS1 X3JIG/94-0098). Весьма полная реализация соглашений по языку Си++ выполнена в широко распространенных компиляторах Тигbо С++ и Вог1аnd С++ фирмы Вог1аnd. Эти программные средства (на которые в тексте делаются ссылки ТС++ и ВС++) и соответствующая фирменная документация были использованы при подготовке настоящей работы. Приводимые в тексте программы в основном отлаживались и выполнялись в интегрированной среде Вог1аnd С++ 3.1 и Turbo С++ 1.01. Версии компиляторов Вог1аnd С++ 4.0 и Вог1аnd С++ 4.5 потребуются читателю только при использовании механизма обработки особых ситуаций (см. главу 12).Содержание пособия подробно отражено в оглавлении, поэтому не будем на нем останавливаться, а приведем только несколько пояснений. Изложение в начальных главах до некоторой степени конспективное, но все разбираемые вопросы сопровождаются примерами. При выборе примеров, оказалось, весьма непросто ограничиваться только уже рассмотренными средствами языка и "не забегать вперед". В тех случаях, когда новые конструкции появляются в примерах до их определения, они тщательно объясняются и комментируются. Тем самым читатель получает возможность неформального предварительно знакомства с некоторыми важными и широко используемыми конструкциями языка Си++, синтаксис которых описывается позже.

Несмотря на то, что в книге изложены практически все основные принципы, средства и механизмы языка Си++, необходимые для объектно-ориентированного программирования, мы не стали только ради моды наспех говорить о технологии объектно-ориентированного программирования СИ++. Здесь мы полностью следуем совету: "... программируя на Си++, нужно... не применять те средства языка, которые представляются вам неясными, как и те, с которыми вы еще не успели ознакомиться" [2].

Несмотря на широкое распространение и популярность языка Си++ стандарты для него отсутствуют. Через несколько лет его практического использования стандартом де-факто стала спецификация языка. АТ&Т С++ ге1еа5е 2.0, разработанная в Ве11 Laboratories фирмы АТ&Т под руководством автора языка Б. Страуструпа. Затем там же появилась усовершенствованная версия 3.0 языка Си++. В настоящее время в Американском национальном институте Стандартов (АNS1) существует комитет по языку Си++ (ХЗJI6). Изданное в 1990 году описание языка с комментариями [2] принято комитетом АN51 в качестве исходного материала для стандартизации Си++. С тех пор рабочей группой WG21 комитета АN51 выпущено несколько предварительных версий стандарта. Последняя из них (но не окончательная) [34] датирована 26 мая 1994 г. (документ АN51 X3JIG/94-0098). Весьма полная реализация соглашений по языку Си++ выполнена в широко распространенных компиляторах Тurbо С++ и Вог1аnd С++ фирмы Вог1аnd. Эти программные средства (на которые в тексте делаются ссылки ТС++ и ВС++) и соответствующая фирменная документация были использованы при подготовке настоящей работы. Приводимые в тексте программы в основном отлаживались и выполнялись в интегрированной среде Вог1аnd С++ 3.1 и Тurbо С++ 1.01. Версии компиляторов Вог1аnd С++ 4.0 и Вог1аnd С++ 4.5 потребуются читателю только при использовании механизма обработки особых ситуаций (см. главу 12).

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

Для успешного усвоения материала пособия от читателя требуются знания основ информатики и некоторые навыки программирования на каком-либо алгоритмическом языке.Материал для главы 12, посвященной исключительным ситуациям, автору помог подобрать С. Г. Чернацкий. Им же написаны и отлажены некоторые программы, помещенные в главе 12.

В заключение предисловия несколько слов о той помощи, которую получил автор и за которую он глубоко признателен. Студенты факультета прикладной математики (ФПМ) Московского государственного институт" электроники и математики (МИЭМ), слушая лекции автора, своими вопросами и замечаниями помогли исправить некоторые шероховатости изложения материала. При печати и редактировании рукописи, а также при анализе результатов выполнения программ автору помогали коллеги по работе. Среди них особую благодарность считаю необходимым выразить В. Г. Воросколевской, Н. Н. Ионцеву, А. В. Утолину, С. Г. Чернацкому, О. В. Шехановой.


Глава 1. НЕФОРМАЛЬНОЕ ВВЕДЕНИЕ В СИ++

1.1. Простые программы на языке Си++

Начнем с объяснения структуры программ на языке Си++, мало обращая внимания на существование языка Си, точнее, не всегда явно отмечая степень преемственности.

Пример 1. Следуя классикам [1,3], приведем программу, выводя их на экран дисплея фразу Hello, Woгd! (Здравствуй, Вселенная!):

//HELLO.СРР - имя файла с программой.
include < iostream >
main()
{
cout " "\nHellо, Wогd!\n";
}

Результат выполнения программы:
Hello, World!

В первой строке текста программы - однострочный комментарий, начинающийся парой символов '// ', заканчивающийся неизображаемым символом "конец строки". Между этими разделителями может быть помещен произвольный текст. В данном примере указано имя файла hello.cpp, текст программы.

Во второй строке помещена команда (директива) препроцессора, обеспечивающая включение в программу средств связи со стандартными потоками ввода и вывода данных. Указанные средства находятся в файле с именем iostream.h (мнемоника: "i" (input) - ввод; (оutput) - вывод; stream - поток; "h" (head) - заголовок). Стандартным потоком вывода по умолчанию считается вывод на экран дисплея. (Стандартный поток ввода обеспечивает чтение данных от клавиатуры.) Третья строка является заголовком функции с именем.Любая программа на языке Си++ должна включать одну и только одну функцию с этим именем. Именно с нее начинается выполнение программы. Перед именем main помещено служебное слово void - спецификатор типа, указывающий, что функция main в данной программе не возвращает никакого значения. Круглые скобки после main требуются в соответствии с форматом (синтаксисом) заголовка любой функции. В них помещается список параметров. В нашем примере параметры не нужны и список пуст.

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

cout << "\nHello, World"\n";

Имя cout в соответствии с информацией, содержащейся в файле iostream.h, является именем объекта, который обеспечивает вывод информации на экран дисплея (в стандартный поток вывода). Информация для вывода передается объекту cout с помощью операции << ("поместить в"). То, что нужно вывести, помещается справа от знака операции <<. В данном случае это строка (строковая константа) "\nНе11о, Worldl\n". Строка в языке Си++ определена как заключенная в кавычки почти любая последовательность символов. Среди них могут встречаться обозначения не изображаемых на экране дисплея управляющих символов. Например, '\n' - обозначение управляющего символа перехода к началу следующей строки экрана. Таким образом, программа выведет на новой строке экрана фразу Hello, world! и переведет курсор в начало следующей строки экрана.

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

До выполнения программы необходимо подготовить ее текст в файле с расширением. СРР; передать этот файл на компиляцию и устранить синтаксические ошибки, выявленные компилятором; безошибочно откомпилировать (получится объектный файл с расширением .OBJ); дополнить объектный файл нужными библиотечными функциями (компоновка) и получить исполняемый модуль программы в файле с расширением. ЕХЕ. Схема подготовки исполняемой программы приведена на рис. 1.1, где перед шагом компиляции показан шаг препроцессорной обработки текста программы. В нашем примере препроцессор обрабатывает директиву #include < iostream.h > и подключает к исходному тексту программы средства ясна с дисплеем (для поддержки операции <<).

РИС. 1.1. Схема подготовки исполняемой программы

Если исходный текст программы подготовлен в файле HELLO.СРР, препроцессор, выполнив директивы препроцессора, сформирует полный текст программы, компилятор создаст объектный файл HELLO.OBJ, выбрав (по умолчанию) для него указанное имя, а компоновщик (редактор связей. Linker) дополнит программу библиотечными функциями, например, для работы с объектом cout и построит модуль HELLO. ЕХЕ. Запустив на выполнение файл HЕLLO.EXE, получим на экране желаемую фразу Hеllo, World!

Особенности выполнения перечисленных действий зависят от конкретного компилятора языка Си++ и той операционной системы, в которой он работает. Технические подробности следует изучить по документации для конкретного программного продукта. Например, при работе с интегрированными средами фирмы Borland необходимая информация может быть получена из руководств пользователя [4, 10]. Независимо от использованного компилятора при работе в MS-DOS исполняемый модуль программы записывается в некоторый каталог (директорию). Если исполняемый модуль создан в каталоге BOOK на диске С:, то для запуска нашей программы нужна команда MS-DOS:

>С:\BOOK\HELLO.EXE < Enter >

Здесь и далее < Enter > обозначает нажатие клавиши Enter.

Пример 2. Для иллюстрации некоторых особенностей определения и инициализации переменных и массивов, а также ввода и вывода данных напишем программу вычисления должностного оклада в соответствии с заданным разрядам единой тарифной сетки (ETC) оплаты труда для работников бюджетных отраслей в Российской Федерации. (Установлена постановлением Правительства Российской Федерации №785 от 14 октября 1992 г. Отменена 27 февраля 1995 г.)

Исходные данные для расчета: минимальная ставка 1-го разряда (smin), массив тарифных коэффициентов, т.е. коэффициентов перерасчета a[], и номер категории (разряда) г. Массив a[] инициализируется в тексте программы, и его нельзя изменить без изменения и трансляции программы. Минимальная ставка определена в программе, но может заменяться другим значением с помощью аргумента командной строки при запуске программы на исполнение. Номер разряда всегда должен вводиться пользователем явно с клавиатуры при выполнении программы. Таким, образом, программа иллюстрирует три способа задания исходных данных для расчета.

//Р1-02.СРР - имя файла с программой                            //1

#include < iostream.h >                                         //2

#include < stretrea.h >                                         //3

int mai(int narg, char **arg)                                   //4

{ float -in = 2250;// Ставка 1-го разряда (1992 r.)             //5

// а[] - массив значений тарифных коэффициентов:                //6

float a[] - ( 1.0, 1.3, 1.69, 1.91. 2.16, 2.44,                 //7

2.76, 3.12, 3.53, 3.99, 4.51, 5.10, 5.76,                       //8

6.61, 7.36, 8.17, 9.07, 10.07 )                                 //9

int r;  // r - разряд тарифной сетки оплаты труда               //10     

cout " "\n";                                                    //11

cout << "Программа вычисляет оклад в  соответствии" ;           //12

cout << "\nс единой тарифной сеткой оплаты "труда" ;            //13

cout << "\n для работников бюджетных отраслей";                 //14

сout << "в России." ;                                           //15

if (narg == 1)                                                  //16

cout << "\nПо умолчанию минимальный оклад ";                    //17

cout << smin " " руб.";                                         //18

cout << "\nПри необходимости нацепить значение";                //19

cout << " минимального";                                        //20

cout << "\nоклада его нужно указать в":                         //21

cout << " командной строке.\n";                                 //22

}                                                               //23

else                                                            //24

{// Чтение из безымянного строкового потока:                    //25

intrstream(arg[l]) " sain;                                      //26

cout << "\n0пределен минимальный оклад в " ;                    //27

cout << sain " " pуб.\n" ;                                      //28

}                                                               //29

cout << "\nВведите номер разряда тарифной сетки: ";             //30

in >> r;;     // Вводится с клавиатуры нoмер разряда            //31

if(r < 1 II r > 18)                                             //32

{cout " "Ошибка в выборе разряда! " ;                           //33

return 1; // Аварийный выход из программы                       //34

}                                                               //35

cout " "Введенному разряду соответствует ставка ";              //36

cout " (long) (a[r-l]*sain) " " руб.";                          //37

return 0     // Безошибочное завершение программы               //38

}                                                               //39

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

В строке 2 - директива (команда) препроцессора, подключающая к программе из файла iostream.h средства связи с библиотечными функциями потокового ввода-вывода. Именно там определены стандартные потоки ввода данных от клавиатуры cin и вывода данных на экран дисплея cout, соответствующие операции чтения из потока >> и записи в поток <<.

Препроцессорная директива из строки 3 включает в текст программы средства для обмена со строковыми потоками. Мнемоника значения файла: str - string (строка), strea - stream (поток), h- head (заголовок). Именно эти средства позволили в строке 26 создать безымянный строковый поток и связать его со строкой arg[l].

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

В списке аргументов функции main (строка 4) первый аргумент int narg служит для передачи в программу количества параметров, использованных в командной строке при запуске программы на выполнение. Значение narg всегда больше или равно 1. Если narg равно 1 (это условие проверяется в строке 16), то никакого параметра в командной строке явно не указано. В этом случае единственный параметр, передаваемый как значение символьного массива, адресуемого указателем arg[0] - это полное имя файла с исполняемым модулем программы. Например, если программа, находящаяся в каталоге BOOK, начинает выполняться после ввода командной строки.

>С: \ВООК\Р1-02.ЕХЕ

то значением, связанным с arg[0], будет строка "с:\воок\р1-02. ЕХЕ". После имени программы в команде операционной системы могут быть указаны через пробелы параметры. В этом случае значением narg будет (k + 1), где k - количество явно указанных параметров. В программе параметры доступны с помощью указателей arg [i], где i=1,.... k. Например, используя команду

>С: \ВООК\Р1-02.ЕХЁ 1800

мы передадим в программу параметр "1800" как значение символьного массива, адресуемого arg[l]. При этом значением narg будет 2, а массив arg[0] , как и раньше, будет указывать на полное имя файла с программой.

В строке 5 определена и инициализирована значением 2250 переменная smin. Спецификатор типа float указывает, что это вещественная арифметическая переменная с внутренним представлением в формате с плавающей точкой. Далее (строки 7-9) определен и инициализирован массив из 18 элементов, каждый из которых является временной вещественного типа со внутренним представлением в формате с плавающей точкой. Количество элементов массива и их значения определяются списком начальных значений, помещенным в фигурных скобках справа, от знака операции присваивания ' = '.

В строке 10 определена, но явно не инициализирована целая (int) переменная r. Ее значение с помощью операции >> ("взять из") вводится при выполнении программы из стандартного входного потока (строка 31). В соответствии с принципом перегрузки (расширения действия) стандартных операций в языке Си++ лексема >> означает операцию извлечения данных из входного потока только в том случае, когда слева от >> находится имя потока. (В противном случае потоки символов >> означает бинарную операцию сдвига вправо, о которой речь пойдет позже.) Итак, в строке 31 используется операция извлечения данных из стандартного входного потока cin. Оператор >> r; преобразует набираемую пользователем на клавиатуре последовательность символов в целочисленное значение и присваивает это значение переменной r. При этом недопустимо появление набранных символов чего-либо отличного от цифр, знаков ' + ', пробелов. Символы, набираемые на клавиатуре и отображаемые на экране дисплея, становятся доступными программе после нажатия клавиши Enter, что одновременно переводит курсор к началу следующей строки. Таким образом, после каждого считывания данных курсор на экране дисплея размещается в начале следующей строки. Обратите внимание на отсутствие символа перевода строки ' \n' в строках 33 и 36.

В строке 16 начинается условный оператор, проверяющий значение narg. Если значение narg равно 1, то по умолчанию используется начальное значение smin, о чем выдается сообщение (Строки 17 - 22) на экран дисплея, и начинает выполняться оператор из строки 30. В противном случае (строки 25 - 28) значение минимального оклада smin выбирается из параметра командной строки операционной системы с помощью указателя arg[l]. Для преобразования этого параметрa (например, строки "1800") в числовое значение используется <<безымянный строковый поток istrstream (arg[1]). Чтение из этого строкового потока выполняет операция извлечения данных из потока >>. Так как справа от этой операции помещена переменная smin типа float, то в параметре можно использовать только символы для изображения числовых значений. Правильность введенного значения для простоты программы не проверяется, и это может быть источником ошибок.Полученное значение smin вместе с пояснительным текстом выводится на экран дисплея (строки 27 - 28), и выполнение условного оператора (строки 16 - 29) завершается.

В строке 30 формируется на экране подсказка пользователю. Затем (строка 31) вводится значение переменной r.

В строках 32 + 35- условный оператор. Вслед за служебным словом if в круглых скобках записано проверяемое логическое выражение - дизъюнкция двух отношений r < 1 и r > 18. Выражение истинно, если значение r меньше 1 или больше 18. В этом случае выполняются заключенные в фигурные скобки { } операторы из строк 33, 34, т.е. печатается сообщение об ошибке, оператор return 1; завершает выполнение программы и передает управление операционной системе, возвращая ей ненулевое значение.

Если номер разряда введен правильно, то выполняются операторы вывода из строк 36,37,38, и программа в строке 38 завершает работу, возвращая операционной системе нулевое значение из оператора return. Отметим, что этот оператор не является обязательным. При его отсутствии возвращаемое программой значение всегда будет нулевым. Если пользователь, находясь в MS-DOS, введет команду (расширение .ZXE по правилам MS-DOS можно опускать)

>С:\ВООК\Р1-02

программа выведет на экран:

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

Здесь в ответ на приглашение (подсказку) программы, пользователь ввел в качестве номера разряда значение 11 и нажал клавишу Enter. Запуск программы из MS-DOS директивой

>С:\ВООК\Р1-02 20400

приведет к такому результату:

Программа вычисляет оклад в соответствии с единой тарифной сеткой оплаты труда
для работников бюджетных отраслей в России.
Определен минимальный оклад в 20400 руб.
Введите номер разряда тарифной сетки: 11
Введенному разряду соответствует ставка 92004 руб.

Следует остановиться на некоторых особенностях вывода на эк-числовых значений. В операторах из строк 18, 28 в стандартный одной поток cout пересылается преобразованное в символьный ряд вещественное значение переменной smin. В операторе из строки 37 выводится значение выражения (long) (a[r-l]*smin); символом '*' здесь обозначается операция умножения. a[r-l] - индексированная переменная, обеспечивающая доступ к 1-му по порядку элементу массива а []. Унаследованная от своего предшественника - языка Си особенность языка Си++ - нумерация элементов массива, начиная с 0. Таким образом, в нашем примере а[0] имеет значение 1.0; а[1]-1.3; ...; a[16]-9.07;а [17]-10. 07. (Лексемой '--' вне текста программ будем обозначать равенство значений справа и слева от знака '-'. Такое соглашение соответствует синтаксису языка Си++ и позволяет отличать присваивание '-'от равенства'--'.) Вводя номер разряда как значение целочисленной переменной r, мы обращаемся в массив за соответствующим ему коэффициентом с помощью индексированного элемента a[r-1]. Полученное значение тарифного коэффициента умножается на значение минимальной ставки smin. Соответствующее выражение заключено в скобки. Перед скобками помещена операция (long) явного приведения типа, преобразующая вычисляемое вещественное значение к целому "длинному" типу. Это сделано для получения значения ставки в рублях без дробной части (дробная часть отбрасывается при приведении типа).

В операторах из строк 18, 28, 37 операция " применяется по несколько раз. Любой из этих операторов можно заменить несколькими. Например, вместо строки 37 можно записать два оператора:

cout << (long)(a(r-1]*smin);
cout << "
руб.";

Результат при выполнении программы будет тем же. Интересно отметить, что в начале 1993 г. оператор из строки 37 имел такой вид:

cout << (int) (a[r-l]*smin) << " руб.";

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

В качестве упражнения читатель может осовременить программу, учтя постановление Правительства Российской Федерации №189 от 27 февраля 1995 г. В соответствии с этим постановлением:

  1.  Устанавливаются следующие тарифные коэффициенты по разрядам: 1,00 (1); 1,12 (2); 1,27 (3); 1,44 (4); 1,62 (5); 1.83(6); 2.07(7); 2.34 (8); 2,64 (9); 2,98 (10); 3,37 (11); 3.81 (12); 4,31 (13); 4,87 (14); 5,50 (15);6,11(16); 6.78 (17); 7,54 (18);
  2.  С 1 марта 1995 г. устанавливается оклад 1-го разряда в размере 39000 руб.
  3.  Увеличиваются оклады первых четырех разрядов на: 1-й разряд - 11700 руб.; 2-й разряд - 10200 руб.; 3-й разряд - 7800 руб.; 4-й разряд - 4800 руб.

(С 1-го мая 1995 г. постановление №189 отменено новым постановлением Правительства Российской Федерации и т.д.)

Глава 2. Лексические основы языка С++

2.1. Общие сведения о программах,
лексемах и алфавите

Общая схема обработки программы и пробельные разделители.

Основная программная единица на языке Си++ - это текстовый файл с названием <имя>. СРР, где СРР - принятое расширение для программ Си++, а имя выбирается достаточно произвольно. Для удобства ссылок и сопоставления программ с их внешними именами целесообразно помещать в начале текста каждой программы строку комментария с именем файла, в котором она находится. Это уже сделано в программах предыдущего параграфа. Текстовый файл с программой Си++ вначале обрабатывает препроцессор (см. рис. 1.1), который распознает команды (директивы) препроцессора (каждая такая команда начинается с символа ' # ') и выполняет их. В приведенных выше программах использованы препроцессорные команды

#include <имя_включаемого_файла>

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

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

В языке Си++ есть два способа задания комментариев. Традиционный способ (ведущий свое происхождение от многих предшествующих языков, например, ПЛ/1, Си и т.д.) определяет комментарий как последовательность символов, ограниченную слева парой символов /*, а справа - парой символов */. Между этими граничными парами может размещаться почти любой текст, в котором разрешено использовать не только символы из алфавита языка Си++, но и другие символы (например, русские буквы):

/* Это комментарий, допустимый и в Си, и в Си++ */

ANSI-стандартом запрещено вкладывать комментарии друг в друга, однако многие компиляторы предусматривают режим, допускающий вложение комментариев.

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

// Это однострочный комментарий, специфичный для языка Си++

Алфавит и лексемы языка СИ++. В алфавит языка Си++ входят:

  •  прописные и строчные буквы латинского алфавита;
  •  цифры 0,1, 2, 3, 4, 5, 6, 7, 8, 9;
  •  специальные знаки:
    "О , I [] ()+- / % \ ;
    . :?< - >_*<< - ^.*

Из символов алфавита формируются лексемы языка:

  •  идентификаторы;
  •  ключевые (служебные, иначе зарезервированные) слова;
  •  константы;
  •  знаки операций;
  •  разделители (знаки пунктуации).

Рассмотрим эти лексические элементы языка подробнее.

2.2. Идентификаторы и служебные слова

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

              RUN      run      hard_RAM_disk      сору_54

Прописные и строчные буквы различаются. Таким образом, в том примере два первых идентификатора различны. На длину различаемой части идентификатора конкретные реализации накладывают ограничение. Компиляторы фирмы Borland различают не более 32-х символов любого идентификатора. Некоторые реализации Си++ на ЭВМ типа VAX допускают идентификаторы длиной до 8.

Ключевые (служебные) слова - это идентификаторы, зарезервированные в языке для специального использования. Ключевые слова Си++:

               asm         double      new         switch  

               auto        else        operator    template  

               break       enum        private     this  

               case        extern      protected   throw   

               catch       float       public      try

               char        for         registe     typedef  

               class       friend      return      typeid

               const       goto        short:      union     

               continue    if          signed      unsigned

               default     inline      sixeof      virtual     

               delete      int         static      void

               do          long        struct      volatile                                                  

                                                   while

Ранее [1] в языке Си++ был зарезервирован в качестве ключевого слова идентификатор overload. Для компиляторов фирмы Borland Си++ и ТС++) дополнительно введены ключевые слова:

 

               cdecl      _export     _loadds    _saveregs

               _cs         far         near      _seg

               _ds         huge        pascal    _ss        

               _es         interrupt    _regparam

Там же введены как служебные слова регистровые переменные:

   

               _CH   _ВH   _СH    _DH    _SI    _SP    _SS

               _AL   _BL   _CL    _DL    _DI    _CS    _ES

               _AX   _ВХ   _СХ    _DX    _BP    _DS    _fIAGS

Отметим, что ранние версии ВС++ и ТС++ не включали в качестве ключевых слов идентификаторы throw, try, typeid, catch.

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

2.3. Константы

Константа (литерал) - это лексема, представляющая изображение фиксированного числового, строкового или символьного (литерного)значения.

Константы делятся на пять групп: целые, вещественные (с плавающей точкой), перечислимые, символьные (литерные) и строковые (строки или литерные строки). Перечислимые константы проект стандарта языка Си++ [2] относит к одному из целочисленных типов.

Компилятор, выделив константу в качестве лексемы, относит её к той или другой группе, а внутри группы - к тому или иному типу данных по ее "внешнему виду" (по форме записи) в исходном тексте и по числовому значению.

Целые константы могут быть десятичными, восьмеричными и шестнадцатеричными.

Десятичная целая константа определена, как последовательность десятичных цифр, начинающаяся не с нуля, если это не число нуль: 16, 484216, 0, 4. Для реализации ТС++ и ВС++ диапазон допустимых целых положительных значений от 0 до 4294967295. Константы, превышающие указанное максимальное значение, вызывают ошибку на этапе компиляции. Отрицательные константы - это константы без знака, к которым применена операция изменения знака. Абсолютные значения отрицательных десятичных констант для ТС++ и ВС++ не должны превышать 2147483648.

Таблица 2.1

Целые константы и выбираемые для них типы

Диапазоны значений констант

Тип данных

j десятичные

восьмеричные

шестнадцатеричные

от 0 до
32767

от 00 до
077777

от 0х0000 до
Ox7FFF

int

-

от 0100000 до
0177777

от 0х8000 до
ОхFFFF

unsigned
int

от 32768 до
2147483647

от 0200000 до
017777777777

от 0х10000 до
Ox7FFFFFFFF

long

от 2147483648 до
4294967295

от 020000000000 до
037777777777

от 0х80000000 до
OxFFFFFFFF

unsigned long

> 4294967295

> 037777777777

> OxFFFFFFFF

ошибка

Восьмеричные целые константы начинаются всегда с нуля: 016 имеет десятичное значение 14. Если в записи восьмеричной константы встретится недопустимая цифра 8 или 9, то это воспринимается как ошибка. В реализациях ТС++ и ВС++ диапазон допустимых значений для положительных восьмеричных констант от 00 до 037777777777. Для отрицательной восьмеричной константы абсолютное значение не должно превышать 020000000000.

Последовательность шестнадцатеричных цифр, которой предшествует 0х, считается шестнадцатеричной константой. В шестнадцатеричные цифры кроме десятичных входят латинские буквы от а (или А) до f (или F). Таким образом, 0х16 имеет десятичное значение 22, a OxF - десятичное значение 15. Диапазон допустимых значений для положительных шестнадцатеричных констант в реализациях ТС++ и ВС++ от 0х0 до OxFFFFFFFF. Для отрицательных шестнадцатеричных констант абсолютные значения не должны превышать 0х80000000.

В зависимости от значения целой константы компилятор по-разному представляет её в памяти ЭВМ. О форме представления данных в памяти ЭВМ говорят, используя термин тип данных. Соответствие между значениями целых констант и автоматически выбираемыми для них компилятором типами данных отображает табл. 2.1, удовлетворяющая требованиям ANSI языка Си, отнесенным ко внутреннему представлению данных для компиляторов семейства IBM PC/XT/AT.

Если программиста по каким-либо причинам не устраивает тот тип, который компилятор приписывает константе, то он может явным образом повлиять на его выбор. Для этого служат суффиксы L, 1 (long) и U, u (unsigned). Например, константа 64L будет иметь тип long,значению 64 должен быть приписан тип int, как это видно из табл. 2.1. Для одной константы можно использовать два суффикса U(и) и L(1), причем в произвольном порядке. Например, константы 0х22U1, OxlILu, ОхЗЗООООUL, Ox551u будут иметь тип unsigned long. При использовании одного суффикса выбирается тот тип данных, который ближе всего соответствует типу, выбираемому для константы по умолчанию (т.е. без суффикса в соответствии с табл. 2.1). Например, 04L есть константа типа long, 04U имеет тип unsigned int и т.д.

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

//Р2-01.СРР - имя файла с текстом программы                //1

#include < iostream.h >                                    //2

void main ()                                               //3

{ cout << " \n sizeof 111 = " << sizeof 111;               //4  

cout << " \n sizeof 111u = " << sizeof 111u;               //5  

cout << " \n sizeof 111L = " << sizeof 111L;               //6  

cout << " \n sizeof 111ul = " << sizeof 111ul;             //7  

cout << " \n\t sizeof 40000 = " << sizeof 40000;           //8  

cout << " \n\t sizeof 40000u = " << sizeof 40000u;         //9  

cout << " \n\t sizeof 40000L = " << sizeof 40000L;         //10  

cout << " \n\t sizeof 40000LU = " << sizeof 40000LU;       //11

}                                                          //12

Здесь использована унарная операция языка Си++ sizeof, позволяющая определить размер в байтах области памяти, выделенной для стоящего справа операнда.

Результат выполнения программы:

sizeof 111 = 2
sizeof 111u = 2
sizeof 111L = 4
sizeof 111uL = 4
sizeof 40000 = 4
sizeof 40000u = 2
sizeof 40000L = 4
sizeof 40000LU = 4

Заслуживает внимания длина десятичной константы 40000u, соответствующая типу unsigned int. По умолчанию (см. табл. 2.1) такой тип не приписывается никакой десятичной константе.

Вещественные константы, т.е. константы с плавающей точкой, даже не отличаясь от целых констант по значению, имеют другую форму внутреннего представления в ЭВМ. Эта форма требует использования арифметики с плавающей точкой при операциях с такими константами. Поэтому компилятор должен уметь распознавать вещественные константы. Распознает он их по внешним признакам. Константа с плавающей точкой может включать следующие шесть частей: целая часть (десятичная целая константа); десятичная точка; дробная часть (десятичная целая константа); признак (символ) экспоненты е или Е; показатель десятичной степени (десятичная целая константа, возможно со знаком); суффикс F (или f) либо L (или l). В записях вещественных констант могут опускаться: целая или дробная часть (но не одновременно); десятичная точка или признак экспоненты с показателем степени (но не одновременно); суффикс. Примеры:

66.    .0    .12   3.14159F   1.12e-2   2E+6L   2.71

При отсутствии суффиксов F (f) или L ( l ) вещественные константы имеют форму внутреннего представления, которой в языке Си++ соответствует тип данных double. Добавив суффикс f или F, константе придают тип float. Константа имеет тип long double, если в ее представлении используется суффикс L или l. Диапазоны возможных значений и длины внутреннего представления (размер в битах) данных вещественного типа указаны в табл. 2.2 [4, 9, 21, 29].

Таблица 2.2

Данные вещественного типа

Тип данных

Размер, бит

Диапазон значений

float

32

от 3.4E-38 до 3.4Е+38

double

64

от 1.7Е-308 до 1.7Е+308

long double

80

от 3.4Е-4932 до 1.IE+4932

Следующая программа показывает, какие участки памяти выделяются вещественным константам разного типа в реализациях ТС++ и ВС++.

//Р2-02.СРР - размеры памяти для вещественных констант
#include
void main ()
{ cout << "\n sizeof 3.141592653589793 =";
cout << sizeof 3.141592653589793;
cout << "\n sizeof 3.14159 =" << sizeof 3.14159;
cout << "\n sizeof 3.l4159f =" << sizeof 3.14l59f;
cout << "\n sizeof 3.14159L =" << sizeof 3.14159L;
}

Результаты выполнения программы - размеры в байтах областей памяти, выделенных для вещественных констант:

sizeof 3.141592653589793 = 8
sizeof 3.14159 = 8
sizeof 3.14159f = 4
sizeof 3.14159L = 10

Перечислимые константы (или константы перечисления [ 3 ], иначе константы перечислимого типа) вводятся с помощью служебного слова еnum. По существу это обычные целочисленные константы (типа int), которым приписаны уникальные и удобные для использования обозначения. В качестве обозначений выбираются произвольные идентификаторы, не совпадающие со служебными словами и именами других объектов программы. Обозначения присваиваются константам с помощью определения, например, такого вида:

enum ( one = 1, two = 2, three = 3 );

Здесь enum - служебное слово, определяющее тип данных "перечисление", one, two, three - условные имена, введенные программистом для обозначения констант 1, 2, 3. После такого определения в программе вместо константы 2 (и наряду с ней) можно использовать ее обозначение two и т.д.

Если в определении перечислимых констант опускать знаки "=" и не указывать числовых значений, то они будут приписываться идентификаторам (именам) по умолчанию. При этом самый левый в фигурных скобках идентификатор получит значение 0, а каждый последующий увеличивается на 1. Например, в соответствии с определением

enum { zero, one, two, three } ;

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

zero==0, one==1, two==2, three==3

Правило о последовательном увеличении на 1 значений перечислимых констант действует и в том случае, когда первым из них (слева в списке) явно присвоены значения. Например, определение

enum { ten = 10, three = 3, four, five, six };

вводит следующие константы:

tеn==10, three==3, four==4, five==5, six==6

Имена перечислимых констант должны быть уникальными, однако к значениям констант это не относится. Одно значение могут иметь разные константы. Например, определение

enum { zero, nought = 0, one, two, paiz = 2, three };

вводит следующие константы:

zero==0, nought==0, one==1, two==2, paiz==2, three==3

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

enum { two = 2, four = two * 2 };

определит константы

two==2 и four==4

Так как отрицательная целая константа - это константа без знака, к которой применена унарная операция " - " (минус), то перечислимые константы могут иметь и отрицательные значения.

Для перечислимых констант может быть введено имя типа, соответствующего приведенному списку констант. Имя типа - это произвольно выбираемый уникальный идентификатор, помещаемый между служебным словом enum и открывающейся фигурной скобкой ' } '.

Например, определение

enum week { sunday, monday, tuesday, wednesday,
thursday, friday, saturday };

только определяет константы sunday==0, monday==l, ..., но и вводит перечислимый тип с именем week, который может в дальнейшем использоваться в определениях и описаниях других объектов.

Символьные (литерные) константы - это один или два символа, заключенные в апострофы. Односимвольные константы имеют стандартный тип char. Для представления их значений могут вводится переменные символьного типа, т.е. типа char. Примеры констант: ' z ' , '*', '\012', '\0' , ' \n' - односимвольные константы. 'db' , '\х07\x07 ', '\n\t' - двух символьные константы. В этих примерах заслуживают внимания последовательности, начинающиеся со знака '\ '. Символ обратной косой черты ' \ ' используется, во-первых, при записи кодов, не имеющих графического изображения, и, во-вторых, символов апостроф ( ' ), обратная косая черта (\), знак вопроса (?) и кавычки ( " ). Кроме того, обратная косая черта позволяет вводить символьные константы, явно задавая их коды в восьмеричном или шестнадцатеричном виде.

Последовательности литер, начинающиеся со знака ' \ ', называют эскейп-последовательностями. В табл. 2.3 приведены их допустимые значения.

В табл. 2.3 000 - строка от 1 до 3 восьмеричных цифр; hh - строка из 1 или 2 шестнадцатеричных цифр. Строка восьмеричных цифр может содержать любое целое восьмеричное число в диапазоне от 0 до 377. Превышение этого верхнего значения приводит к ошибке. Наиболее часто в программах используется последовательность '\0', обозначающая пустую (null) литеру. В эскейп-последовательности вслед за \х может быть записано любое количество шестнадцатеричных цифр. Таким образом, допустимы, например, константа \x0004F и ее аналог \х4F. Однако числовое значение не должно выходить за диапазон от 0х0 до OXFF. Большее значение вызывает ошибку при компиляции. Если непосредственно за символом ' \ ' поместить символ, не предусмотренный таблицей 2.3, то результат будет неопределенным. Если среди восьмеричных цифр последовательности \ооо или шестнадцатеричных в \xhh встретится неподходящий символ, то это считается концом восьмеричного или соответственно шестнадцатеричного кода.

Таблица2.3 

Допустимые ESC-последовательности в языке Си++

Изображение

Внутренний код

Обозначаемый символ (название)

Реакция или смысл

0х07

bel (audible bell)

Звуковой сигнал

\b

0х08

bs (backspace)

Возврат на шаг (забой)

\f

ОхОС

ff (form feed)

Перевод страницы (формата)

\n

ОхОА

lf (line feed)

Перевод строки (новая строка)

\r

0х0D

cr (carriage return)

Возврат каретки

\t

0х09

ht (horizontal tab)

Табуляция горизонтальная

\v

ОхОВ

vt (vertical tab)

Табуляция вертикальная

\\

Ох5С

\ (backslash)

Обратная косая черта

\'

0х27

' (single quote)

Апостроф (одинарная кавычка)

\"

0х22

" (double quote)

Двойная кавычка

\?

Ox3F

? (question mark)

Вопросительный знак

\000

000

Любой (octal number)

Восьмеричный код символа

\xhh

Oxhh

Любой (hex number)

Шестнадцатеричный код символа

Для использования внутренних кодов символов при работе, например, с экраном дисплея нужна таблица, в которой каждому изображаемому на экране символу соответствует числовое значение его кода в десятичном, восьмеричном, шестнадцатеричном представлении. На IBM-совместимых ПЭВМ применяется таблица кодов ASCII (см. Приложение 1). Выбирая из кодовой таблицы подходящее значение, можно (если это полезно по каким-либо причинам) использовать их в программе вместо явных изображений символов. Например:

//Р2-ОЗ.СРР - использование кодов символьных констант
#include < iostream.h >
void main()
{ cout << ' \хОA' << ' \х48' << '\х65' << '\х6C' << '\х6С';
cout << '\x6F' << '\х2С';
cout << '\40'<< '\127'<< '\157';
cout << '\162' << '\154' << '\144' << '\41';
}

Программа с новой строки выведет на экран:

Hello, World!

В первом и втором из операторов вывода использованы шестнадцатеричные, а в третьем и четвертом - восьмеричные коды символов: '\хОА' - '\n'; '\х48' - 'Н'; ...; '\40' -"пробел"; '\127' - 'W'; ...; '\41'-' ! '.

Значением символьной константы является числовое значение ее внутреннего кода. Как упоминалось, в Си++ односимвольная константа имеет тип char, т.е. занимает в памяти 1 байт. Двух символьные константы вида '\t\n' или '\r\07' представляются двухбайтовыми значениями типа int, причем первый символ ( ' \t или ' \г ' в примерах) размещается в младшем байте (с меньшим адресом), а второй (в примерах ' \n ' или ' \07 ' ) - в старшем байте.

Перечисленные соглашения иллюстрирует следующая программа:

//Р2-04.СРР - длины внутренних представлений символьных
//                     констант
#include < iostream.h >
void main ()
{ cout << " \n Длины символьных (литерных) констант ";
cout << " (в байтах) : ";
cout << "\nsireof\'z\ ' = " << sizeof ' z ';
cout << "\nsizeof\'\\n\' = " << sizeof ' \n';
cout << " \nsizeof\'\\n\\t\" = " << sizeof '\n\f';
cout << "\nsizeof\'\\x07\\x07\' = " << sizeof '\x07\x07';
cout << "\nsizeof\'\\x0004F\' = " << sizeof '\x0004F';
cout << "\nsizeof\'\\x4F\' = " << sizeof '\x4F';
cout << "\nsizeof\'\\111\' = " << sizeof '\111' ;
cout << "\nДесятичное значение ";
cout << "кода символа \'\\x0004F\ ' = " << (int) ' \x0004E';
cout << "\nДесятичное значение ";
cout << "кода символа \'\\x4F\ ' = " << (int) ' \x4F';
cout << "\nДесятичное значение хода пробела = ";
cout << (int) ' ';
}

В результате выполнения программы на экран будет выведено:

Длины символьных (литерных) констант (в байтах):
sizeof ' z ' = 1
sizeof '\n' = 1
sizeof '\n\t = 2
sizeof '\x07\x07' = 2
sizeof '\x0004F' = 1
sizeof '\x4F' = 1
sizeof '\111' = 1
Десятичное значение кода символа '\x0004F' = 79
Десятичноe значение кода символа '\x4F' = 79
Десятичное значение кода пробела = 32

Строка, или строковая константа, иногда называемая литерной строкой, определяется как последовательность символов, заключенная в кавычки (не в апострофы):

"Это строка, навиваемая также строковой константой"

Говоря о строках, иногда используют термины "строковый литерал" [5], "стринговый литерал", "стринговая константа", "стринг" [3], однако мы будем придерживаться традиционной терминологии, так как опыт показывает, что возможное неоднозначное толкование термина "строка" легко устраняется контекстом. (В английском языке этой проблемы не возникает, так как используются два различных слова: line - линия, строка текста и string - серия, ряд, последовательность, гирлянда...) Среди символов строки могут быть эскейп-последовательности, т.е. сочетания, соответствующие не изображаемым символьным константам или символам, задаваемым значениями их внутренних кодов. В этом случае, как и в представлениях отдельных символов, они начинаются с обратной косой черты ' \ ';

//Р2-05.СРР - строки c эскейп-последовательностями
#include < iostream.h >
void main ()
{ cout << " \nЭто строка, \nиначе - \"стринг\",\nиначе - ";
   cout << " \ "строковый литeрал\" . ";
}

При выводе на экран дисплея этих строк эскейп-последовательности '\n' и '\" ' обеспечат такое размещение информации:

Это строка,
иначе - "стринг",
иначе - "строковый литерал".

Обратите внимание на наличие символа ' \ ' перед двойной кавычкой внутри строки. Именно по наличию этого символа компилятор отличает внутреннюю кавычку от кавычки, ограничивающей строку.

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

"Строка - это массив символов."
"Строка имеет тип char[] . "

эквивалентна одной строке:

"Строка - это массив символов. Строка имеет тип char[] . "

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

"Обычно транслятор отводит \
каждой строковой константе \
отдельное место в памяти ЭВМ."

Следующая программа иллюстрирует разные формы "склеивания" строк в одну:

//Р2-06.СРР - конкатенация строк
#include < iostream.h >
void main ()
{ cout << "\n1" "9" "93"
                  " год" ;         // При выводe пробелы будут удалены
cout << " начался c\
                  пятницы. " ;         // Пробелы из этой строки сохранятся
}

Результат выполнения программы:

1993 год начался с                пятницы.

Обратите внимание на количество пробелов в результате перед словом "пятницы". Продолжением перенесенной с помощью символа ' \ ' строки считается любая информация на следующей строке, в том числе и пробелы.

Размещая строку в памяти, транслятор автоматически добавляет в ее конец символ ' \0 ' , т.е. нулевой байт. Таким образом, количество символов во внутреннем представлении строки на 1 больше числа символов в ее записи. Пустая строка хранится как один символ " \0 ".

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

//Р2-07.СРР - инициализация массива строковой константой
#include < iostream.h >
void main ()
{ char stroka [] = "REPETITIO EST MATER STUDIORUM";
cout << "\nsizeof stroka = " << sizeof stroka;
cout << "\nstroka = " << stroka;
}

Результат выполнения программы:

sizeof stroka = 30
stroka = REPETITIO EST MATER STUDIORUM

Обратите внимание, что при определении массива char после его имени stroka в скобках [] не указано количество элементов. Размер массива подсчитывается автоматически во время инициализации и равен количеству символов в строковой инициализирующей константе (в нашем случае 29) плюс один элемент для завершающего символа ' \0 '.

Кавычки не входят в строку, а служат ее ограничителями при записи в программе. В строке может быть один символ, например, "А" -строка из одного символа. Однако в отличие от символьной константы 'А' длина внутреннего представления строки "А" равна 2. Строка может быть пустой " " , при этом ее длина равна 1. Однако символьная константа не может быть пустой, т.е. запись ' ' в большинстве реализаций недопустима.

//Р2-08.СРР - длины строк и символьных констант (литер)
#include < iostream.h >
void main ()
{ cout << "\nsizeof\"\" = " << sizeof " ";
cout << "\tsizeof\'A\' = " << sizeof 'A';
cout << "\tsizeof\"A\" = " << sizeof "A";
cout << "\nsizeof\'\\n\' = " << sizeof '\n';
cout << "\tsizeof\"\\n\" = " << sizeof "\n ";
cout << "\nsizeof\'\\xFF\" = " << sizeof '\xFF';
cout << "\tsizeof\"\\xFF\" = " << sizeof "\xFF";
}

Результат выполнения программы:

sizeof** = 1          sizeof'A' = 1          sizeof"A" = 2
sizeof'\n' = 1          sizeof"\n" = 2       sizeof'\xFF' = 1
sizeof"\xFF" = 2

2.4. Знаки операций

Знаки операций обеспечивают формирование и последующее вычисление выражений. Выражение есть правило для получения значения. Один и тот же знак операции может употребляться в различных выражениях и по-разному интерпретироваться в зависимости от контекста. Для изображения операций в большинстве случаев используется несколько символов. В ANSI-стандарте языка Си определены следующие знаки операций [3,5 ]:

                 [ ]        ( )        .       ->       ++        - -

                  &          *         +       -        ~          !

                sizeof       /         %       <<       >>         <

                  >         <=        >=       ==       !=         ^

                  |         &&        ||       ?:        =        *=

                 \=         %=        +=       -=       <<=       >>=

                 &=         ^=        |=       ,         #        ##

Дополнительно к перечисленным в Си++ введены:

                 : :       .*        ->*      new     delete   typeid

За исключением операций [], () и ? : все знаки операций распознаются компилятором как отдельные лексемы. В зависимости от контекста одна и та же лексема может обозначать разные операции. Например, бинарная операция & - это поразрядная конъюнкция, а унарная операция & - это операция получения адреса.

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

Унарные операции

&           операция получения адреса операнда;

*           операция обращения по адресу, т.е. раскрытия ссылки, иначе операция разыменования (доступа по адресу к значению того объекта, на который указывает операнд). Операндом должен быть адрес;

-           унарный минус - изменяет знак арифметического операнда;

+           унарный плюс (введен для симметрии с унарным минусом); поразрядное инвертирование внутреннего двоичного кода целочисленного аргумента (побитовое отрицание);

~          логическое отрицание (НЕ) значения операнда; применяется к скалярным операндам; целочисленный результат 0 (если операнд ненулевой, т.е. истинный) или 1 (если операнд нулевой, т.е. ложный). В качестве логических значений в языке Си++ используют целые числа: о - ложь и не нуль (! о) - истина. Отрицанием любого ненулевого числа будет 0, а отрицанием нуля будет 1. Таким образом: ! l равно 0; ! 2 равно 0; ! (-5) равно 0; ! 0 равно 1;

++          увеличение на единицу (инкремент или авто увеличение): префиксная операция - увеличение значения операнда на 1 до его использования; постфиксная операция - увеличение значения операнда на 1 после его использования. Операнд не может быть константой либо другим праводопустимым выражением. Записи ++5 или 84++ будут неверными. Операндом не может быть и произвольное выражение. Например, ++( j+k ) также неверная запись. Операндом унарных операций ++ и - - должны быть всегда лево допустимые выражения, например, переменные (разных типов); уменьшение на единицу (декремент или авто уменьшение) -операция, операндом которой не может быть константа и право допустимое выражение: префиксная операция - уменьшение значения операнда на 1 до его использования; постфиксная операция - уменьшение значения операнда на 1 после его использования;

sizeof           операция вычисления размера (в байтах) для объекта того типа, который имеет операнд. Разрешены два формата операции: sizeof унарное_выражвие и sizeof(тип).

Примеры использования операций с простейшими унарными выражениями, к которым относятся константы, приводились в связи с изложением материала о константах (см. Р2-04.СРР). Проиллюстрируем применение этой операции со стандартными типами:

//Р2-09.СРР - размеры разных типов данных
#include < iostream.h >
void main ()
{ cout << " \nsizeof(int) = << " sizeof (int);
cout << "\tsizeof(short) = << " sizeof (short) ;
cout << " \tsizeof(long) = << " sizeof (long) ;
cout << "\nsizeof(float) = << " sizeof (float) ;
cout << "\tsizeof(double) = << " sizeof (double) ;
cout << "\tsizeof(char) = << " sizeof(char) ;
}

Результат выполнения программы:

sizeof(int) = 2        sizeof(short) = 2        sizeof(long) = 4
sizeof(float) = 4     sizeof(double) = 8     sizeof(char) = 1

Бинарные операции. Эти операции делятся на следующие группы:

  •  аддитивные;
  •  мультипликативные;
  •  сдвигов;
  •  поразрядные;
  •  операции отношений;
  •  логические;
  •  присваивания;
  •  выбора компонента структурированного объекта;
  •  операции с компонентами классов;
  •  операция "запятая";
  •  скобки в качестве операций.

Аддитивные операции:

+        бинарный плюс (сложение арифметических операндов или сложение указателя с целочисленным операндом);

-        бинарный минус (вычитание арифметических операндов или указателей).

Мультипликативные операции:

*        умножение операндов арифметического типа;

/        деление операндов арифметического типа. Операция стандартна. При целочисленных операндах абсолютное значение результата округляется до целого. Например, 20/3 равно 6, -20/3 равняется -6, (-20) / 3 равно -6, 20/(-3) равно -6;

%        получение остатка от деления целочисленных операндов (деление по модулю). При неотрицательных операндах остаток положительный. В противном случае остаток определяется реализацией. В компиляторах ТС++ и ВС++:

13%4 равняется 1, (-13) %4 равняется -1,
13 % (-4) равно + 1, а (-13) % (-4) равняется-1

При ненулевом делителе для целочисленных операндов всегда выполняется соотношение: (a/b)*b + а%b равно а.

Операции сдвига (определены только для целочисленных операндов). Формат выражения с операцией сдвига:

операнд_левый операция_сдвига операнд_правый

"        сдвиг влево битового представления значения левого целочисленного операнда на количество разрядов, равное значению правого целочисленного операнда;

"        сдвиг вправо битового представления значения левого целочисленного операнда на количество разрядов, равное значению правого целочисленного операнда.

Поразрядные операции:

&         поразрядная конъюнкция (И) битовых представлений значений целочисленных операндов;

|        поразрядная дизъюнкция (ИЛИ) битовых представлений значений целочисленных операндов;

^        поразрядное исключающее ИЛИ битовых представлений значений целочисленных операндов.

Следующая программа иллюстрирует особенности операций сдвига и поразрядных операций.

//Р2-10.СРР - операции сдвига и поразрядные операции
#include < iostream.h >
void main ()
{ cout << "\n4<< 2 равняется" << (4<<2);
cout << "\t5>>1 равняется " << (5>>1);
cout << "\n6&5 равняется " << (6&5);
cout << "\t6|5 равняется " << (6 | 5);
cout << "\t6^5 равняется " << (6^5);
}

Результат выполнения программы:

4 <<2 равняется 16
5 >>1 равняется 2
6&5 равняется 4
6|5 равняется 7
6^5 равняется 3

Тем, кто давно не обращал внимания на битовое представление целых чисел, напоминаем, что двоичный код для 4 равен 100, для 5 -это 101, для 6 - 110 и т.д. При сдвиге влево на 2 позиции код 100 становится равным 10000 (десятичное значение равно 16). Остальные результаты операций сдвига и поразрядных операций могут быть прослежены аналогично.

Обратите внимание, что сдвиг влево на n позиций эквивалентен умножению значения на 2n, а сдвиг вправо кода уменьшает соответствующее значение в 2n раз с отбрасыванием дробной части результата. (Поэтому 5>>l равно 2.)

Операции отношения (сравнения):

<          меньше, чем;

>          больше, чем;

<=          меньше или равно;

>=          больше или равно;

==          равно;

!=          не равно;

Операнды в операциях отношения арифметического типа или указатели. Результат целочисленный: 0 (ложь) или 1 (истина). Последние две операции (операции сравнения на равенство) имеют более низкий приоритет по сравнению с остальными операциями отношения. Таким образом, выражение (х < B == А < х) есть 1 тогда и только тогда, когда значение х находится в интервале от А до в. (Вначале вычисляются х < B и A < х,а к результатам применяется операция сравнения на равенство ==.)

Логические бинарные операции:

&&         конъюнкция (И) арифметических операндов или отношений. Целочисленный результат 0 (ложь) или 1 (истина);

||        дизъюнкция (ИЛИ) арифметических операндов или отношений. Целочисленный результат 0 (ложь) или 1 (истина).

(Вспомните о существовании унарной операции отрицания ' f '.)

Следующая программа иллюстрирует некоторые особенности операций отношения и логических операций:

//Р2-11.СРР - операции отношения и логические операции
#include < iostream.h >
void main ()
{ cout << "\n3<5 равняется " << (3<5);
cout << "\t3>5 равняется " << (3>5);
cout << "\3==5 равняется " << (3==5);
cout << " \t3!=5 равняется " << (3!=5);
cout << "\n!=5 || 3==5 равняется " << (3!=5| |3==5);
cout << " \n+4>5 && 3+5>4 && 4+5>3 равняется " << (3+4>5 && 3+5>4 && 4+5>3);
}

Результат выполнения программы:

3<5 равняется 1
3>5 равняется О
3==5 равняется О
3!=5 равняется 1
3!=5 | | 3==5 равняется 1
3+4>5 && 3+5>4 && 4+5>3 равняется 1

Операции присваивания.

В качестве левого операнда в операциях присваивания может использоваться только модифицируемое l-значение - ссылка на некоторую именованную область памяти, значение которой доступно изменениям. Термин l-значение (left value), иначе - лево допустимое выражение, происходит от объяснения действия операции присваивания Е = D, в которой операнд Е слева от знака операции присваивания может быть только модифицируемым l-значением. Примером модифицируемого l-значения служит имя переменной, которой выделена память и соответствует некоторый класс памяти. Итак, перечислим операции присваивания:

=        присвоить значение выражения-операнда из правой части операнду левой части: Р = 10.3 - 2*х;

*=        присвоить операнду левой части произведение значений обоих операндов:
            P *= 2 эквивалентно Р = Р * 2;

/=        присвоить операнду левой части частное от деления значения левого операнда на значение правого:
            р /= 2.2 - d эквивалентно Р = Р / (2.2 - d);

%=        присвоить операнду левой части остаток от деления целочисленного значения левого операнда на целочисленное значение правого операнда:
            N %= 3 эквивалентно N = N % 3;

+=        присвоить операнду левой части сумму значений обоих операндов:
             А += в эквивалентно А = А + B;

-=        присвоить операнду левой части разность значений левого и правого операндов:
            х ~= 4.3 - z эквивалентно х = х - (4.3 - z ) ;

<<=        присвоить целочисленному операнду левой части значение, полученное сдвигом влево его битового представления на количество разрядов, равное значению правого целочисленного операнда:
             а <<= 4 эквивалентно а = а << 4;

>>=        присвоить целочисленному операнду левой части значение, полученное сдвигом вправо его битового представления на количество разрядов, равное значению правого целочисленного операнда:
         а >>= 4 эквивалентно а = а >> 4;

&=         присвоить целочисленному операнду левой части значение, полученное поразрядной конъюнкцией (И) его битового представления с битовым представлением целочисленного операнда правой части:
            e &= 44 эквивалентно e = е & 44;

| =        присвоить целочисленному операнду левой части значение, полученное поразрядной дизъюнкцией (ИЛИ) его битового представления с битовым представлением целочисленного операнда правой части:
             а | = b эквивалентно а = а | b;

^=        присвоить целочисленному операнду левой части значение, полученное применением поразрядной операции исключающего ИЛИ к битовым представлениям значений обоих операндов:
             z ^= х + у эквивалентно z = z ^ (x + у).

Обратите внимание, что для всех операций сокращенная форма присваивания EI ор= Е2 эквивалентна El = E1 ор (Е2),где ор-обозначение операции.

Для иллюстрации некоторых особенностей выполнения операций присваивания рассмотрим следующую программу:

//Р2-12.СРР - операции присваивания
#include < iostream.h >
void main ()
{ int k ;
cout << "\n\n k = 35/4 равняется " << (k=35/4);
cout << "\t k /= 1 + 1 + 2 равняется " << (k/=1+1+2);
cout << "\n k *= 5 - 2 равняется " << (k*=5-2);
cout << "\t k %= 3 + 2 равняется " << (k%=3+2);
cout << "\n k += 21/3 равняется " << (k+=21/3);
cout << "\t k -= 6 - 6/2 равняется " << (k-=6-6/2);
cout << "\n k <<= 2 равняется " << (k<<= 2);
cout << "\t k "= 6-5 равняется " << (k"=6-5);
cout << "\n k &= 9 + 4 равняется " << (k&=9+4);
cout << "\t k |= 8 - 2 равняется " << (k|=8-2);
cout << "\n k ^= 10 равняется " << (k^=1O);
}

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

Результаты выполнения:

k = 35/4 равняется 8        k /= 1 + 1 + 2 равняется 2
k *= 5 - 2 равняется 6      k %= 3 + 2 равняется 1
k += 21/3 равняется 8      k -= 6 - 6/2 равняется 5
k <<= 2 равняется 20       k <<= 6-5 равняется 10
k &= 9 + 4 равняется 8    k |= 8 - 2 равняется 14
k ^= 10 равняется 4

Полученные числовые значения, во-первых, подтверждают эквивалентность записей El ор= Е2 иЕ1 = E1 ор (Е2). Кроме того, анализируя результаты, можно еще раз рассмотреть особенности поразрядных операций. Двоичный код для k, равного 5, будет 101. Сдвиг влево на 2 дает 10100 (десятичное 20). Затем сдвиг на 1 вправо формирует код 1010 (десятичное 10). Поразрядная конъюнкция 1010&1101 дает 1000 (десятичное 8). Затем 1000 | 110 дает значение 1110 (десятичное 14). Результатом 1110^1010 будет 0100 (десятичное 4).

Операции выбора компонентов структурированного объекта:

(точка) прямой выбор (выделение) компонента структурированного объекта, например объединения. Формат применения операции:

имя структурированного объекта.имя компонента

->        косвенный выбор (выделение) компонента структурированного объекта, адресуемого указателем. При использовании операции требуется, чтобы с объектом был связан указатель. В этом случае формат применения операции имеет вид:

указатель на структурированный объект -> имя компонента

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

Операции с компонентами классов:

*        прямое обращение к компоненту класса по имени объекта и указателю на компонент;

->*        косвенное обращение к компоненту класса через указатель на объект и указатель на компонент.Комментировать эти операции затруднительно, не введя понятие класса, что будет сделано позже.

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

/ / Р2-13.СРР - изменение видимости внешней переменной
#include < iostream.h >
int k = 15; // Глобальная переменная с начальным значением
void main ()
{ int k = 10; // Локальная переменная с начальным значением
cout << "\nВнешняя переменная k = " << : : k;
cout << "\nВнутренняя переменная k = " << k;
::k = О;
cout << "\nВнешняя переменная k = " << : :k;
cout << "\nВнутренняя переменная k = " << k;
}

Результат выполнения программы:

Внешняя переменная k = 15
Внутренняя переменная k = 10
Внешняя переменная k = О
Внутренняя переменная k = 10

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

Запятая в качестве операции:

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

/ / Р2-14.СРР - запятая в качестве знака операции
#include < iostream.h >
void main ()
{ int d;
cout << "\nВыражение d = 4, d*2 равно " << (d=4, d*2);
cout << ", d равно " << d;
}

Программа выведет на экран:

Выражение d = 4, d*2 равно 8, d равно 4

Круглые ' () ' и квадратные ' [] ' скобки играют роль бинарных операций при вызове функций и индексировании элементов массивов. Для программиста, мало знакомого с техникой использования указателей, мысль о том, что скобки в ряде случаев являются бинарными операциями, часто даже не приходит в голову. И это тогда, когда он практически в каждой программе обращается к функциям или применяет индексированные переменные. Итак, отметим, что скобки могут служить бинарными операциями, особенности и возможности которых достойны внимания.

Круглые скобки обязательны в обращении к функции:

имя_функции (список_аргументов)

Здесь операндами служат имя_функции и список_аргументов. Результат вызова вычисляется в теле функции, структуру которого задаст ее определение.

В выражении имя_массива [индекс] операндами для операции ' [] ' служат имя_массива и индекс. В языках Си и Си++ принято, что индексы массивов начинаются с нуля, т.е. массив int z[3] из трех элементов включает индексированные элементы z [0], z [ l ] , z [ 2 ]. Это соглашение языка становится очевидным, если учесть, что индекс определяет не номер элемента, а его смещение относительно начала массива. Таким образом, z[0] - обращение к первому элементу, z[l] - обращение ко второму элементу и т.д. В следующей программе показано, как обычно используют квадратные скобки при работе с элементами массива:

/ / Р2-15.СРР - работа с элементами массива
#include < iostream.h >
void main ()
{ char x[ ] = "DIXI" ; / / "Я СКАЗАЛ" (высказался)
int i = 0;
while (x(i] ! = '\0' )
cout << "\n" << x [i++] ;
}

Результат - слово "DIXI", написанное в столбик (сверху вниз):

D
I
х
I

Оператор цикла с заголовком while выполняется, пока верно выражение в скобках, т.е. пока очередной символ массива не равен ' \0 '. При каждом вычислении выражения x[i++] используется текущее значение i , которое затем увеличивается на 1. В данном случае квадратные скобки играют роль бинарной операции, а операндами служат имя массива х и индекс i++.

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

Именно из-за появления в языке Си++ механизма расширения действия (перегрузки - overload) стандартных операций и в связи с необходимостью расширять действие скобок-операций на необычные для них операнды скобки ' [ ] ', ' ( ) ' отнесены к стандартным операциям языка Си++.

Условная операция. В отличие от унарных и бинарных операций условная операция используется с тремя операндами. В изображении условной операции два размещенных не подряд символа ' ? ', и ' : ' и три операнда-выражения:

выражениие_1 ? выражение_ 2 : выражение_ 3

Первым вычисляется значение выражения_1. Если оно истинно, т.е. не равно нулю, то вычисляется значение выражения_2, которое становится результатом. Если при вычислении выражения_1 получится 0, то в качестве результата берется значение выражения_3. Классический пример:

х < о ? -х : х ;

Выражение возвращает абсолютное значение переменной х.

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

( имя_типа)операнд

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

(long) l - внутреннее представление имеет длину 4 байта;
(char) l - внутреннее представление имеет длину 1 байт.

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

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

имя типа(операнд)

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

long (2) - внутреннее представление имеет длину 4 байта;
double (2) - внутреннее представление имеет длину 8 байтов.

Однако будет недопустимым выражение:

unsigned long(2) / / Ошибка!

Операции new и delete для динамического распределения памяти.

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

new имя_ типа либо
new имя_типа инициализатор

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

указатель = new имя_типа инициализатор

Здесь необязательный инициализатор - это выражение в круглых скобках. Указатель, которому присваивается получаемое значение адреса, должен относиться к тому же типу данных, что и имя_типа в операции new. О размерах участков памяти уже упоминалось в связи с константами разных типов. Поэтому, не вводя пока других понятий, относящихся к типам данных, приведем несложные примеры.

Операция new float выделяет участок памяти размером 4 байта. Операция new int (l5) выделяет участок памяти в 2 байта и инициализирует этот участок целым значением 15. Синтаксис использования операций new и delete предполагает применение указателей. Предварительно каждый указатель должен быть определен.

Определение указателя имеет вид:

тип *имя_указателя ;

Имя_указателя - это идентификатор. В качестве типа можно использовать, например, уже упомянутые стандартные типы int, long, float, double, char. Таким образом, int *h; - определение указателя h, который может быть связан с участком памяти, выделенным для величины целого типа. Введя с помощью определения указатель, можно присвоить ему возвращаемое операцией new значение:

h = new int(15);

В дальнейшем доступ к выделенному участку памяти обеспечивает выражение *h.

В случае отсутствия в операции new инициализатора значение, которое заносится в выделенный участок памяти, не определено и не следует рассчитывать, что там будет, например, нуль.

Если в качестве имени типа в операции new используется массив, то для массива должны быть полностью определены все размерности. Но и при этом инициализация участка памяти, выделяемого для массива, запрещена. Подробнее о выделении памяти для массивов речь пойдет в главе 5.

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

Для явного освобождения выделенного операцией new участка памяти используется оператор delete указатель; где указатель адресует освобождаемый участок памяти, ранее выделенный с помощью операции new.

Например, delete h; - освободит участок памяти, связанный с указателем h. Повторное применение операции delete к тому же указателю дает неопределенный результат. Также непредсказуем результат применения этой операции к указателю, получившему значение без использования операции new. Однако применение delete к указателю с нулевым значением не запрещено, хотя и не имеет особого смысла.

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

/ / Р2-16.СРР - динамическое распределение памяти
#include < iostream.h >
void main ()
{ int *i;
i = new int(1);
cout << "\n*i=" << *i << " \t i=" << i;
i = new int(5);
cout << "\t*i=" << *i << " \t\t i=" << i;
i = new int (2**i);
cout << "\n*i="<<*i<<"\t i=" << i;
i = new int (2**i);
cout << "\t*i=" << *i << "\t\t i" << i;
delete i;
cout << "\n
После освобождения памяти: ";
cout << "\n*i=" << *i << "\t i=" <<I;
delete i; / /
Некорректное применение операции
cout << "\t*i=" << *i << "\t i=" << i;
}

Результат выполнения программы:

*i=l     i=0x91790004     *i=5         i=0x917a0004

*i-10    i=0x917b0004     *i=20        i=0ac917c0004

После освобождения памяти:

*i=20    i=0x917c0004     *i=-28292    i=0x917c0004

Обратите внимание, что после выполнения первого оператора delete i; значение указателя i и содержимое связанного с ним участка памяти *i еще сохранились. После вторичного применения операции delete значение указателя не изменилось, а содержимое связанного с ним участка памяти "испортилось". Указанные изменения и (или) сохранения значения *і не обязательны и полностью зависят от реализации и от конкретного исполнения программы. В ряде случаев при работе с интегрированной средой ВС++ версии 3.1 выдается сообщение об ошибке:

Null pointer assignment

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

delete [] указатель;

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

Ранги операций. Завершая краткий обзор операций языка Си++, приведем таблицу приоритетов, или рангов операций [4,9].

Таблица 2.4

Приоритеты операций

Ранг

Операции

Ассоциативность

1

( ) [ ] -> : : .

->

2

! ~ + - ++ -- & * (тип) sizeof new delete тип ( ) (функциональное преобразование типа)

<-

3

. * -> *

->

4

* / % (мультипликативные бинарные операции)

->

5

+ - (аддитивные бинарные операции)

->

6

<< >>

->

7

< <= >= >

->

8

== ! =

->

9

&

->

10

^

->

11

|

->

12

&&

->

13

| |

->

14

?: (условная операция)

<-

15

= *= /= %= += -= & = ^= |= <<= >>=

<-

16

, (операция "запятая")

->

Грамматика языка Си++ определяет 16 категорий приоритетов операций. В табл. 2.4 категории приоритетов названы рангами. Операции ранга 1 имеют наивысший приоритет. Операции одного ранга имеют одинаковый приоритет, и если их в выражении несколько, то они выполняются в соответствии с правилом ассоциативности либо слева направо (->), либо справа налево (<-). Если один и тот же знак операции приведен в таблице дважды, то первое появление (с меньшим по номеру, т.е. старшим по приоритету, рангом) соответствует унарной операции, а второе - бинарной.

Отметим, что кроме стандартных режимов использования операций язык Си++ допускает расширение их действия на объекты классов, вводимых пользователем или уже определенных в конкретной реализации языка.Примером такого расширения (перегрузки) является операция извлечения данных из потока << и операция передачи данных в выходной поток >>,применяемые к потокам ввода cin и вывода cout.

2.5. Разделители

Разделители,или знаки пунктуации, входят в число лексем языка:

[ ] ( ) { } , ; : ... * = # &

Квадратные скобки ' [ ] ' ограничивают индексы одно- и многомерных массивов и индексированных элементов.

// Одномерный массив из пяти элементов:

int А [] = (О, 2, 4, 6, 8 );
// е - двумерный массив - матрица размерности 3х2:
int х, е[3] [2];
// Начальному элементу массива е и переменном х
// присваивается значение 4 (третий элемент массива А):
е[0] [0] = х = А[2];

Круглые скобки ' ( ) ' :

1) выделяют условные выражения (в операторе "если" ):
if (х < 0) х = -х; // Модуль значения
                         / / арифметической переменной

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

float F(float x, int k) // Определение функции
{ тело_функции }
float F(float, int); // Описание функции - ее прототип
. . .
F(Z,n) ; // Вызов функции

3) обязательны в определении указателя на функцию:

int (*func)(void) ; / / Определение указателя на функцию

4) группируют выражения, изменяя естественную последовательность выполнения операций:

у = (а + b) / с; / / Изменение приоритета операций

5) входят как обязательные элементы в операторы циклов:

for (i = О, j = 1; i < j ; i += 2, j++) тело_цикла;
while (i < j)
тело цикла;
do
тело цикла while (k > 0);

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

long i = 12L; int j; // Определения переменных
j = int(i) ; // Функциональная запись преобразования
float В; // Определение переменной
В = (float) j; // Явное приведение типа

j получает значение 12L, преобразованное к типу int. Затем B получает значение 12, преобразованное к типу float.

7) применение круглых скобок настоятельно рекомендуется в макроопределениях, обрабатываемых препроцессором:

#define R(x,y) sqrt ((x)*(x) + (у)*(у))

Это позволяет использовать в качестве параметров макровызовов арифметические выражения любой сложности и не сталкиваться с нарушениями приоритетов операций (см. гл. 8).

Фигурные скобки ' { } ' обозначают соответственно начало и конец составного оператора или блока. Пример использования составного оператора в условном операторе:

if (d > x) { d - -; x++; }

Пример блока, являющегося телом функции:

float exponent (float x, int n)
{ float d = 1.0 ;
int i = 0
; if (x==0) return 0.0;
for( ; i < abs(n) ; i++, d*=x);
return n > 0 ? d : 1.0/d;
}

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

Фигурные скобки используются для выделения списка компонентов в определениях типов структур, объединений, классов:

struct cell { char *b; // Определение типа
int cc;                     / / структуры
double U[6];
};
union smes
{ unsigned int ii; // Определение типа
char cc[2];        / / объединения };
class sir
{ int В;              / / Определение класса public:
int X, D;
sir(int);
};

Обратите внимание на необходимость точки с запятой после описания (определения) каждого типа.

Фигурные скобки используются при инициализации массивов и структур при их определении:

// Инициализация массива:
int month[ ] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 };
// Инициализация структуры stock типа mixture:
struct mixture { int ii; double dd; char cc; }
stock = { 666, 3.67, '\f' };

В примере mixture - имя типа структуры с тремя компонентами разных типов, stock - имя конкретной структуры типа mixture. Компоненты ii, dd, cc структуры stock получают значения при инициализации.

В следующей программе описана структура с именем constant и выполнена ее инициализация, т.е. ее компонентам типа long double присвоены значения знаменитых констант:

//Р2-17.СРР - фигурные скобки в структуре и при ее
//                      инициализации
#include < iostream.h >
void main ()
{ struct
{ long double pi;
long double c;
}
constant = { 3.1415926S35897932385,
                   2.7182818284590452354 }
cout << "\n" << constant.pі << "\t" << constant.c;
}

На печать (на экран дисплея) выводятся:

3.141593   2.718282

Обратите внимание на точность (только 7 значащих цифр) выбираемую по умолчанию при работе со стандартным потоком вывода.

Запятая ' , ' разделяет элементы списков. Во-первых, это списки начальных значений, присваиваемых индексированным элементам массивов и компонентам структур при их инициализации:

char name [ ] = { 'С', 'и' , 'р', 'a', 'н','о'}; // Это не строка!
struct A {int x; float у; char x;}
F= {3, 18.4, '
с'};

Другой пример списков - списки формальных и фактических параметров и их спецификаций в функциях.

Третье использование запятой как разделителя - в заголовке оператора цикла for:

for ( х = pl, у = р2, i = 2 ; i < n;
z = x + у, x = у, у = z, i++);

(В данном примере вычисляется n-й член ряда Фибоначчи z по значениям первых двух pl и р2.)

Запятая как разделитель используется также в описаниях и определениях объектов (переменных) одного типа:

int i, n; float х, у, z, р1, р2;

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

/ / Р2-18.СРР - запятая как разделитель и как знак операции
#include < iostream.h >
void main ()
{ int i = 1, m[ ] = { i, (i=2,i*i), i };
cout << "\ni = " << i << "\Результат на экране:

i = 2   m[0] = 1   m[1] = 4   m[2] = 2

В данном примере запятая в круглых скобках выступает в роли знака операции. Операция присваивания "=" имеет более высокий приоритет, чем операция "запятая". Поэтому вначале i получает значение 2, затем вычисляется произведение i*i, и этот результат служит значением выражения в скобках. Однако значением переменной i остается 2.

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

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

Точка с запятой ' ; ' завершает каждый оператор, каждое определение (кроме определения функции) и каждое описание.

Любое допустимое выражение, за которым следует ' ; ' , воспринимается как оператор. Это справедливо и для пустого выражения, т.е. отдельный символ "точка с запятой" считается пустым оператором. Пустой оператор часто используется как тело цикла. Примером может служить цикл for, приведенный выше для иллюстрации особенностей использования запятой в качестве разделителя. (Вычисляется n-й член ряда Фибоначчи.)

Примеры операторов-выражений:

// Результат выполнения - только изменение значения i F(z,4);
// Результат определяется телом функции с именем F

Двоеточие ' : ' служит для отделения (соединения) метки и помечаeмого ею оператора:

метка: оператор;

Метка - это идентификатор. Таким образом, допустимы, например, такие помеченные операторы:

xyz: а = (Ь - с) * (d - с); сc: z *= 1;

Второе применение двоеточия - описание производного класса, где имя класса отделяется от списка базовых классов двоеточием:

ключ_класса имя_класса: базовый_список {список _компонентов}

ключ_класса имя_класса: базовый_список {список _компонентов} ключ_класса - это одно из трех служебных слов: struct, union, class. Имя_класса - произвольно выбираемый идентификатор. Базовый_список - это список имен порождающих (базовых) классов. Не определяя списка компонентов (к чему вернемся, рассматривая классы), приведем пример определения производного класса:

class x: А, В {список_компонентов};

Многоточие - это три точки '... ' без пробелов между ними. Оно используется для обозначения переменного числа параметров у функции при ее определении и описании (при задании ее прототипа). При работе на языке Си программист очень часто использует библиотечные функции со списком параметров переменной длины для форматных ввода и вывода. Их прототипы выглядят следующим образом:

int printf(char *format, ...);
int acanf (char *format, ...);

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

Подготовка своих функций с переменным количеством параметров на языке Си++ требует применения средств адресной арифметики, например, предоставляемых головным файлом stdarg.h. Описание макросов va_arg, va_end, va_start для организации доступа из тела такой функции к спискам ее параметров приведено в главе 6.

Звездочка '*', как уже упоминалось, используется в качестве знака операции умножения и знака операции разыменования (получения значения через указатель). В описаниях и определениях звездочка означает, что описывается указатель на значение использованного в объявлении типа:

int *point;       // Указатель на величину типа int
char **refer;   // Указатель на указатель
                      // на величину типа char

Знак '=', как уже упоминалось, является обозначением операции присваивания. Кроме того, в определении он отделяет описание объекта от списка его инициализации:

struct {char x, int у} А = {'z', 1918};
int F = 66;

В списке формальных параметров функции знак ' = ' указывает на выбираемое по умолчанию значение аргумента (фактического параметра):

char CC(int Z = 12, char L = '\0' ) {....}

По умолчанию параметр z равен 12, параметр L равен ' \о '.

Символ '#' (знак номера или диеза в музыке) используется для обозначения директив (команд) препроцессора. Если этот символ является первым отличным от пробела символом в строке программы, то строка воспринимается как директива препроцессора.

Символ '&' играет роль разделителя при определении переменных типа ссылки:

int В; // Описание переменной
int &A = В; // А - ссылка на В

Отметив использование символа '&' в качестве разделителя при описании ссылок, отложим подробное рассмотрение ссылок.

Глава 3. СКАЛЯРНЫЕ ТИПЫ И ВЫРАЖЕНИЯ

3.1. Основные и производные типы

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

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

  •  char (символьный);
  •  short (короткий целый);
  •  int (целый);
  •  long (длинный целый);
  •  float (вещественный);
  •  double (вещественный с удвоенной точностью);
  •  void (отсутствие значения).

При определении переменных им можно приписывать начальные значения, которые заносятся в выделяемую для них память в процессе инициализации. Примеры определений (описания с инициализацией):

char newsimbol = ' \n ';
long filebagin = OL;
double pi = 3.1415926535897932385;

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

long double zebra, stop;

вводит переменные с именами zebra и stop вещественного типа повышенной точности, но явно не присваивает этим переменным никаких начальных значений.

Употребляемые как отдельно, так и вместе с другими именами типов служебные слова unsigned (беззнаковый) и signed (знаковый) позволяют для арифметического или символьного типа выбирать способ учета знакового разряда:

unsigned int i, j, k;        // Значения от 0 до 65535
unsigned long L, M ,N; // Значения от 0 до 4294967295
unsigned char с, s;       // Значения от 0 до 255

При таком определении переменные i, j, k могут принимать только целые положительные значения в диапазоне от 0 до 65535 и т.д.

Применение в определениях типов отдельных служебных слов fait, char, short, long эквивалентно signed int, signed char, signed short, signed long. Именно поэтому служебное слова signed обычно опускается в определениях и описаниях. Использование при задании типа только одного unsigned эквивалентно unsigned int.

При операциях с беззнаковыми (unsigned) целыми не возникает переполнений, так как используется арифметика по модулю 2 в степени n, где n - количество битовых разрядов, выделяемых для представления соответствующих значений.

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

// РЗ-01.СРР - размеры разных типов данных
#include < iostream.h >
void main ()
{ int i;

Таблица 3.1

Основные типы данных

Тип данных

Размер, бит

Диапазон значений

Примечание-назначение типа

unsigned char

8

0...255

Небольшие целью числа и коды символов

char

8

-128...127

Очень малью целью числа и ASCII-коды

еnum

16

-32768...32767

Упорядоченные наборы целых значений

unsigned int

16

0...65535

Большие целые и счетчики циклов

short int

16

-32768...32767

Небольшие целые, управление циклами

int

16

-32768...32767

Небольшие целые, управление циклами

unsigned long

32

0...4294967295

Астрономические расстояния

long

32

-2147483648... ...2147483647

Большие числа, популяции

float

32

3.4Е-38...3.4Е+38

Научные расчеты (7 значащих цифр)

double

64

1.7Е-308...1.7Е+308

Научные расчеты (15 значащих цифр)

long double

80

3.4Е-4932...  ...1.1Е+4932

Финансовые расчеты (19 значащих цифр)

unsigned int ui;
long 1;
unsigned long ul;
double d;
long double ld;
cout << "\n sizeof (int) = " << sizeof(i);
cout << "\t sizeof (unsigned int) = " << sizeof(ui);
cout << "\n sizeof (long) = " << sizeof(1);
cout << "\t sizeof (unsigned long) = " << sizeof(ul);
cout << "\n sizeof (double) = " << sizeof(d);
cout << "\t sizeof (long double) = " << sizeof(ld);
}

Результаты выполнения:

sizeof (int) = 2          sizeof (unsigned int) = 2
sizeof (long) = 4      sizeof (unsigned long) = 4
sizeof (double) = 8  sizeof (long double) = 10

В табл. 3.1 приведены типы данных, их размеры в памяти и диапазоны допустимых значений для компиляторов, ориентированных на ПЭВМ семейства IBM PC/XT/AT (см., например, [9], с. 19). В таблицу не включены указатели, так как они будут подробно рассмотрены позже.

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

typedef unsigned char COD;
COD simbol;

введен новый тип COD - сокращенное обозначение для unsigned char и переменная этого типа simbol, значениями которой являются беззнаковые числа в диапазоне от 0 до 255.

Рассматривая переменные, мы пока использовали базовые (предопределенные целиком или фундаментальные) типы, для обозначения которых употребляются по отдельности и в допустимых сочетаниях служебные слова char, int, signed, double, long, unsigned, float, short, void.

Из этих базовых типов с помощью операций ' * ', ' & ', ' [ ] ', ' ( ) ' и механизмов определения типов структурированных данных (классов, структур, объединений) можно конструировать множество производных типов. Обозначив именем type допустимый тип, приведем форматы некоторых производных типов:

type имя []

массив объектов заданного типа type. Например:

long int м[5]; - пять объектов типа long int, доступ к которым обеспечивают индексированные переменные M[0], М[1], М[2], М[3], М[4].

type1 имя (tуре2);

функция, принимающая аргумент типа type2 и возвращающая значение типа typel. Например:

int fl (void); - функция, не требующая аргументов и возвращающая значение типа int; void f2 (double); - функция, принимающая аргумент типа double и не возвращающая значений.

type *имя;

указатель на объекты типа type. Например, char *ptr ; определяет указатель ptr на объекты типа char.

type *имя[];

массив указателей на объекты типа type.

type (*имя) [];

указатель на массив объектов типа type.

typel *имя(type2);

функция, принимающая аргумент типа type2 и возвращающая указатель на объект типа typel.

typel (*имя) (type2);

указатель на функцию, принимающую параметр типа type2 и возвращающую значение типа typel. Например, описание int (*ptr) (char) ; определяет указатель ptr на функцию, принимающую параметр типа char и возвращающую целое значение типа int.

typel *(*имя) (type2);

указатель на функцию, принимающую параметр типа type2 и возвращающую указатель на объект типа typel.

type & имя = имя _объекта _типа _type;

инициализированная ссылка на объект типа type. Например, unsigned char &cc = simbol; определяет ссылку cc на объект типа unsigned char. Предполагается, что ранее в программе присутствует определение unsigned char simbol;

type1(&имя)(type2);

ссылка на функцию, возвращающую значение заданного типа type1 и принимающую параметр типа type2.

struct имя { type1 имя1; type2 имя2; };

тип структура в данном случае с двумя компонентами, которые имеют типы typel и type2. Например, struct ST{int x; char у; float z; ) ; -определяет структурный тип ST структуры с тремя компонентами разных типов: целая x, символьная у, вещественная z. (Количество компонентов в определении структуры может быть произвольным.)

union имя {type1 имя1; type2 имя2;};

тип объединение (в данном случае двух компонентов с типами typel, type2). Например, union UN{int m; char с [ 2 ];}; - объединяющий тип ОМ -объединение целой переменной m и двух элементов символьного массива с [0] и с[1] (Количество компонентов объединения может быть любым.)

class имя {type1 имя1; type2 имя2 (type3);};

класс, включающий в данном случае два компонента - объект типа type1 и функцию типа type2 с аргументом типа type3. Например: class А (int N; float F(char);}; -тип А - класс, компонентами которого служат целая переменная N и вещественная функция F с символьным аргументом. (Количество компонентов класса может быть произвольным.)

Еще один производный тип языка Си++ - это указатели на компоненты классов. Так как это понятие нужно вводить одновременно с определениями механизмов классов, то отложим рассмотрение этих указателей.

Все возможные производные типы принято разделять на скалярные (scalar), агрегатные (agregate) и функции (function). К скалярным типам относят арифметические типы, перечислимые типы, указатели и ссылки (ссылки введены только в Си++, но не в языке Си). Агрегатные типы, которые также называют структурированными, включают массивы, структуры, объединения и классы (последние только в Си++).

3.2. Объекты и их атрибуты

Одним из основных понятий языка Си++ является унаследованное из языка Си и предшествующих языков понятие объекта как некоторой области памяти. Переменная - это частный случай объекта как именованной области памяти. Отличительной чертой переменной является возможность связывать с ее именем различные значения, совокупность Которых определяется типом переменной. При определении значения переменной в соответствующую ей область памяти помещается некоторый код. Это может происходить либо во время компиляции, либо во время исполнения программы. В первом случае говорят об инициализации, во втором случае - о присваивании. Операция присваивания Е = B содержит имя переменной (E) и некоторое выражение (B). Имя переменной есть частный случай более общего понятия - "лево допустимое выражение" (left value expression), или l-значение. Название "лево допустимое выражение" произошло как раз от изображения операции присваивания, так как только l-значение может быть использовано в качестве ее левого операнда. Лево допустимое выражение определяет в общем случае ссылку на некоторый объект. Частным случаем такой ссылки является имя переменной.

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

Вернемся к объектам как к участкам памяти.

Так как с объектом связано значение, то кроме l-выражения для объекта задается тип, который:

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

Имя объекта как частный случай лево допустимого выражения обеспечивает как получение значения объекта, так и изменение этого значения. Не любые выражения являются лево допустимыми. К лево допустимым относятся:

  •  имена скалярных арифметических и символьных переменных;
  •  имена переменных, принадлежащих массивам (индексированные переменные);
  •  имена указателей;
  •  уточненные имена компонентов структурированных данных (структур, объединений, классов):

имя_структуры.имя_компонента;

  •  o выражения, обеспечивающие косвенный выбор компонентов структурированных данных (структур, объединений, классов):

указатвль_на_объект -> имя_компонвнта;

  •  лево допустимые выражения, заключенные в круглые скобки;
  •  ссылки на объекты;
  •  выражения с операцией разыменования ' * ' ;
  •  вызовы функций, возвращающих ссылки на объекты.

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

  •  имя функции;
  •  имя массива;
  •  имя константы;
  •  вызов функции, не возвращающей ссылки.

Кроме типов, для объектов явно либо по умолчанию определяются:

  •  класс памяти (задает размещение объекта);
  •  область (сфера) действия, связанного с объектом идентификатора (имени);
  •  видимость объекта;
  •  продолжительность существования объектов и их имен;
  •  тип компоновки (связывания).

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

Тип определяет размер памяти, выделенной для значения объекта, правила интерпретации двоичных кодов значений объекта и набор допустимых операций. Типы рассмотрены в связи с переменными. Табл. 3.1 содержит основные типы и их свойства.

Класс памяти определяет размещение объекта в памяти и продолжительность его существования. Для явного задания класса памяти при определении (описании) объекта используются или подразумеваются по умолчанию следующие спецификаторы:

auto (автоматически выделяемая, локальная память)

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

register (автоматически выделяемая, по возможности регистровая память)

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

static (внутренний тип компоновки и статическая продолжительность существования)

Объект, описанный со спецификатором static, будет существовать в пределах того файла с исходным текстом программы (модуля), где он определен. Класс памяти static может приписываться переменным и функциям.

extern (внешний тип компоновки и статическая продолжительность существования)

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

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

Область (сфера) действия идентификатора (имени) - это часть программы, в которой идентификатор может быть использован для доступа к связанному с ним объекту [5,12], Область действия зависит от того, где и как определены объекты и описаны идентификаторы. Здесь имеются следующие возможности: блок, функция, прототип функции, файл (модуль) и класс.

Если идентификатор описан (определен) в блоке, то область его действия - от точки описания до конца блока. Когда блок является телом функции, то в нем определены не только описанные в нем объекты, но и указанные в заголовке функции формальные параметры. Таким образом, сфера действия формальных параметров в определении функции есть тело функции. Например, следующая функция вычисляет факториал значения своего аргумента. (Сфера действия для параметра z и переменных, описанных в теле функции, есть блок - тело функции):

              long fact (int z)                      //1

              { long m = 1;                          //2

              if (z < 0) return 0;                   //3

              for (int i = 1; i < z; m = ++i * m);   //4

              return m;                              //5

              }                                      //6

Область действия: для переменной m - строки 2-6; для переменной i - строки 4-6; для параметра z - блок в целом.

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

Прототип функции является сферой действия идентификаторов, указанных в списке формальных параметров. Конец этой сферы действия совпадает с концом прототипа функции. Например, в прототипе

float expon (float d, int m);

определены идентификаторы d, в, не существующие в других частях программы, где помещен данный прототип. Для примера рассмотрим программу, в которой используется только что определенная функция fact () для вычисления факториала:

//РЗ-02.СРР - сфера действия формальных параметров
//                    прототипа
#include < iostream.h >
long fact (int z)
{ long m = 1;
if (z < 0) return 0;
for (int i = 1; i < z; m = ++i * m);
return m;
}
main ()
{ int j = l, К = 3;
long fact(int К = 0);// Прототип функции
for ( ; j <= К; j++)
cout << "\n arg = " << j <<
              " arg ! = " << fact(j);
}

Результат выполнения программы:

arg = 1        arg != 1
arg = 2        arg != 2
arg = 3        arg != 6

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

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

//РЗ-ОЗ.СРР - область действия глобальных имен
#include < iostream.h >
int LC;
char C[] = "Фраза";
void WW(void)
{ LC = sizeof(C);}
void Prin (void)
{ cout << "\n Длина строки С = " << LC; }
void main (void) { WW (); Prin (); }

Результат выполнения программы:

Длина строки С = 6

В программе три функции и два глобальных объекта - массив C и целая LC, через которые реализуется связь функций по данным.

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

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

С понятием области (сферы) действия связано пространство имен - область, в пределах которой идентификатор должен быть "уникальным" [5,12]. С учетом пространства имен используемые в программе идентификаторы делятся на четыре группы [5,9]:

  •  Имена меток, используемых в операторе goto. Эти имена должны быть уникальными в той функции, где они введены.
  •  Имена структур, классов, объединений и перечислимых данных должны быть уникальными в пределах того блока, где они определены. Если эти имена описаны вне функций и классов, то они должны быть уникальными относительно всех таких же глобальных имен.
  •  Имена компонентов структур, объединений, классов должны быть уникальными в пределах соответствующего определения. В разных структурах, объединениях, классах допустимы компоненты с одинаковыми именами.
  •  Имена переменных и функций, названия типов, которые введены пользователем (с помощью служебного слова typedef), и имена элементов перечислений должны быть уникальными в сфере определения. Например, внешние идентификаторы должны быть уникальными среди внешних и т.д.

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

//РЗ-04.СРР - переопределение внешнего имени внутри блока
#include < iostream.h >
void main ()
{ char cc[] = "Число"; // Массив автоматической памяти
float pi = 3.1415926; // Переменная типа float
cout << "\n Обращение к внешнему имени: pi = " << pi;
{ // Переменная типа double переопределяет pi:
double pi = 3.1415926535897932385;
// Видимы double pi и массив сс[]:
cout << '\n' << сс " double pi = " << pi;
}
// Видимы float pi и массив cc[]:
cout << '\n' << cc " float pi = " << pi;
}

Результат выполнения программы:

Обращение к внешнему имени: pi = 3.1415926
Число double pi = 3.1415926535897932385
Число float pi = 3.1415926

Достаточно часто сфера (область) действия идентификатора и видимость связанного с ним объекта совпадают. Область действия может превышать видимость, но обратное невозможно, что иллюстрирует данный пример. За описанием переменной double pi внутри блока внешнее имя переменной float pi становится невидимым. Массив char сс [] определен и видим во всей программе, а переменная float pi видима только вне вложенного блока, внутри которого действует описание double pi. Таким образом, float pi невидима во внутреннем блоке, хотя сферой действия для имени pi является вся программа. Для переменной double pi и сферой действия, и сферой видимости служит внутренний блок, вне которого она недоступна и не существует.

Второй пример изменения видимости объектов при входе в блок:

//РЗ-05.СРР - переопределение внешнего имени внутри блока
#include < iostream.h >
void main ()
{int k = О, j = 15;
{ cout << "\nВнешняя для блока переменная k = " << k;
char k = 'А'; / / Определена внутренняя переменная
cout << "\nВнутренняя переменная k = " << k;
cout << "\nВидимая в блоке переменная j = " << j;
j = 30; // Изменили значение внешней переменной
}           // Конец блока
cout << "\nВне блока: k = " << k << ", j = " << j;
}

Результат выполнения программы:

Внешняя для блока переменная k = 0
Внутренняя переменная k = А
Видимая в блоке переменная j = 15
Вне блока: k = 0, j = 30

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

Язык Си++ позволяет изменить видимость объектов с помощью операции '::'. Программа Р2-13.срр, иллюстрирующая возможность такого изменения видимости, приведена при описании операции указания области действия '::'. Рассмотрим еще один пример обращения к "невидимой" внутри функции внешней строке с помощью операции указания области действия:

//РЗ-06.СРР - доступ из функции к внешнему объекту,
//                  имя которого переопределено в теле функции
#include < iostream.h >
char cc[] - "Внешний массив";
void func(void)    // Определение функции
{ char cc[] = "Внутренний массив";
// Обращение к локальному объекту сс:
cout << '\n' << сс;
// Обращение к глобальному объекту cc:
cout << '\n' <<::сс;
}
void main (void)
{ void func(void); // Прототип функции
func ();                    // Вызов функции
}

Результат выполнения программы:

Внутренний массив
Внешний массив

Следующая программа и соответствующая ей схема (рис. 3.1) обобщают соглашения о сферах действия идентификаторов и о видимости объектов. Программа готовится в виде одного текстового файла. В программе три функции, из которых одна главная (main):

// РЗ-07.СРР - файл с текстом программы (модуль)
#include < iostream.h >
char dc [] = "Объект 1"; // Глобальный для модуля объект 1
void func1(void)
{ cout << "\nf1.dc =" << dc; // Виден глобальный объект 1
char dc[] = "Объект 2"; // Локальный для func1() объект 2
cout << "\nf1.dc =" << dc; // Виден локальный объект 2
{ // Внутренний блок для func1()
// Виден локальный для func1() объект 2:
cout << "\nf1.block.dc =" << dc;
// Локализованный в блоке объект 3:
char dc [] = "Объект З";
// Виден локальный объект 3:
cout << "\nf1.block.dc =" << dc;
// Виден глобальный объект 1:
cout << "\nf1.block . ::dc = " << ::dc;
}
// Конец блока
// Виден локальный для func1() объект 2:
cout << "\nf1.dc = " << dc;
// Виден глобальный объект 1:
cout << "\nf1. : :dc = " << ::dc; } // Конец функции func1()
void func2(char *dc)        // dc - параметр функции
{ cout << "\nf 2. параметр. dc =" << dc;   // Виден параметр
// Виден глобальный объект 1:
cout << "\nf2. : :dc = " << ::dc;
{ // Внутренний блок для func2()
// Локализованный в блоке объект 4:
char dc[] = "Объект 4";
// Виден локальный для func2 () объект 4:
cout << "\nf2.dc = " << dc;
}               // Конец блока
}               // Конец функции func2 ()
void main(void)
{ // Виден глобальный объект 1:
cout << "\nfmain.dc = " << dc;
char dc[] = "Объект 5"; // Локальный для main ()объект 5
func1();
func2(dc);

Рис. 3.1. Видимость объектов, связанных с одним идентификатором (именем), в однофайловой программе

// Виден локальный для main () объект 5:
cout << "\nfmain.dc = " << dc;
}

Результат выполнения программы:

fmain.dc = Объект 1
f1.dc = Объект 1
f1.dc = Объект 2
f1.block.dc = Объект 2
f1.block.dc = Объект З
f1.block. : :dc = Объект 1
fl.dc = Объект 2
fl.::dc= Объект 1
f2.параметр.dc = Объект 5
f2.::dc = Объект 1
f2.dc = Объект 4
fmain.dc = Объект 5

На рис.3.1 для имени объекта с типом type1 областью действия служит файл в целом; однако видимость этого объекта различна внутри блоков и функций. Если определение typel имя; (char dc[] = "объект1";) поместить в конце файла, то ничего хорошего не получится - действие определения не распространяется вверх по тексту программы. Все попытки обратиться к глобальному "Объекту1" в этом случае приведут к синтаксическим ошибкам, выявленным компилятором.

Объект, определенный в тексте программы ниже (позже) своего первого использования, должен быть описан в той функции, где он используется, с атрибутом extern:

void fl(void)
{ extern int ex; // Описание внешней переменной
cout << "\nfl : ex = " << ex;
}
void main(void)
{ f1();}
int ex = 33; // Определение с инициализацией

Результат выполнения программы:

fl:ex = 33

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

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

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

//РЗ-08.СРР - инициализация и существование локальных
//                     статических объектов
#include < iostream.h >
int counter (void)        // Определение функции
{static int К;    // Статическая переменная,
return ++K;    // локализованная в теле функции }
void main (void)
{int counter(void);   // Прототип функции
int К = 3;              // Локальная переменная функции main
for( ; К != 0; К- -)
{ cout << "\nАвтоматическая К = " << К;
cout << "\tСчетчик=" << counter();
}
}

Результат выполнения программы:

Автоматическая К = 3      Счетчик = 1
Автоматическая К = 2      Счетчик = 2
Автоматическая К = 1      Счетчик = 3

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

В основной функции примера определена целая переменная к с локальной продолжительностью существования. Такие переменные называются автоматическими. Они создаются при каждом входе в блок или функцию, где они описаны, и уничтожаются при выходе. Как образно выразились авторы руководств [5, 12], объекты с локальной продолжительностью жизни "ведут более случайное существование", чем объекты со статической продолжительностью. Объекты с локальной продолжительностью должны быть инициализированы только явным образом, иначе их начальное значение непредсказуемо. Память для них при входе в блок или функцию выделяется в регистрах или в стеке. Область действия для объекта с локальной продолжительностью существования всегда локальна - это блок или функция. Для задания локальной продолжительности при описании объекта можно использовать спецификатор класса памяти auto, однако он всегда избыточен, т.к. этот класс памяти по умолчанию приписывается всем объектам, определенным в блоке или функции.

Следует обратить внимание, что не для всех объектов с локальной областью действия определена локальная продолжительность существования. Например, в функции counter ( ) переменная к имеет локальную область действия (тело функции), однако для нее спецификатором static определена статическая продолжительность существования.

Объекты с динамической продолжительностью существования создаются (получают память) и уничтожаются с помощью явных операторов в процессе выполнения программы. Для создания используется операция new или функция malloc (), а для уничтожения - операция delete или функция free (). Пример программы с динамическим распределением памяти для объектов приведен при описании возможностей операций new и delete. Приведем программу, в которой для тех же целей используются библиотечные функции malloc() и free (). Указанные функции находятся в стандартной библиотеке языка Си и его наследника - языка Си++, а их прототипы включены в заголовочный файл alloc .h. Прототип функции для выделения памяти:

void *malloc(int size);

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

//РЗ-09.СРР - динамическое выделение памяти для объектов
#include < alloc.h > // Прототипы malloc() и free ()
#include < iostream.h > // Для cout
void main (void)
{int *t; // Память выделена только для t, но не для *t
           // Память выделена для m и *m;
int *m = (int *)
malloc(sizeof(int));
*m = 10; t = m; // Запомнили значение указателя
                    // Память выделена для *m:
m = (int *)
malloc(sizeof(int));
*m = 20;
cout << "\n Второе значение *m = " << *m;
free(m); // Освободить память, выделенную для *m
cout << "\n Первое значение *m = " << *t;
free(t); // Освободить память, выделенную для *m
}

При определении указатель t получил память, но память для целого *t не выделена. При определении указателя в функцией malloc() выделена память для целого значения *m. В этот участок памяти заносится значение 10.

При втором использовании malloc() указатель m устанавливается на новый участок памяти. В *m заносится значение 20 и следует печать. Остальное поясняют комментарии.

Результат выполнения программы:

Второе значение *m = 20
Первое значение *m = 10

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

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

Например, имена статических объектов (static) локализованы в своем файле и могут быть использованы как имена других объектов и функций в других независимо транслируемых файлах. Такие имена имеют внутренний тип компоновки (связывания). Если имена объектов или функций определены со спецификатором extern, то имя будет иметь внешний тип компоновки (связывания). Рассмотрим следующую программу, функции которой и определения переменных рассредоточены по трем текстовым файлам:

// ---------------------------------------- модуль 1 --------------------------------------

//РЗ-10-1.СРР - первый файл многомодульной программы
int К = 0;                     // Для К - внешнее связывание
void counter(void)        // Для counter - внешнее связывание
{static int K_IN =0;     // Для К_IN - внутреннее связывание К += ++K_IN;
}

// ----------------------------------------- Модуль 2 -------------------------------------

//РЗ-10-2.СРР - второй (основной) файл программы
#include < iostream.h >
void main(void)
{ void counter(void); // Прототип - внешнее связывание
void display(void);    // Прототип - внешнее связывание
                               // К - локальный объект - внутреннее связывание:
for (int К = О; К < 3; К++)
{ cout << "\nПараметр цикла К =" <<К;
counter (); // Изменяет свою К_ IN и внешнюю К
display ();
}
}

//----------------------------------------- Модуль 3 ---------------------------------------

//РЗ-10-З.СРР - третий файл программы
#include < iostream.h >
void display(void)  // Для display - внешнее связывание
{ extern int К;       // Для К - внешнее связывание static
int К _IN = 0;      // Для К_IN - внутреннее связывание
cout << "\nВнешнее К = " << К++ <<
             " Внутреннее К_IN из функции display = " <<
К_IN++;
}

Результат выполнения:

Параметр цикла К = О
Внешнее К = 1
Внутреннее K_IN из функции display = 0
Параметр цикла К = 1
Внешнее К = 4
Внутреннее K_IN из функции display = 1
Параметр цикла К = 2
Внешнее К = 8
Внутреннее K_IN из функции display = 2

Анализируя текст и результаты программы, обратите внимание на следующее:

  •  внешняя переменная с именем K является общей для файлов 1 и 3 (внешнее связывание);
  •  внутренняя переменная с именем K существует только в основном модуле;
  •  статические переменные с именем K_IN различны в модулях 1 и 3, т.е. это различные объекты с одинаковым именем. Для каждого из них реализуется внутреннее связывание.

Для некоторых имен тип компоновки не существует. К ним относятся параметры функций, имена объектов, локализованных в блоке (без спецификаторов extern), идентификаторы, не являющиеся именами объектов и функций (например, имя введенного пользователем типа).

3.3. Определения и описания

Различия между определениями и описаниями. Все взаимосвязанные атрибуты объектов (тип, класс памяти, область (сфера) действия имени, видимость, продолжительность существования, тип компоновки) приписываются объекту с помощью определений и описаний, а также контекстом, в котором эти определения и описания появляются. Определения устанавливают атрибуты объектов, резервируют для них память и связывают эти объекты с именами (идентификаторами). Кроме определений, в тексте программы могут присутствовать описания, каждое из которых делает указанные в них идентификаторы известными компилятору. В переводной литературе и особенно в документации наряду с терминами "описание" и "определение" используют "объявление" и "декларация". Не втягиваясь в терминологическую дискуссию, остановимся на варианте, примененном в переводах работ [1] и [25].

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

Однако это не всегда возможно и не всегда требуется.

Определять и описывать можно следующие объекты:

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

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

s m тип имя1 иниц. 1, имя2 иниц. 2, . . .;

где

s - спецификатор класса памяти (auto, static, extern, register, typedef) - подробно описан в п. 3.2;

m - модификатор const или volatile;

тип- один из основных типов (табл. 3.1);

имя - идентификатор;

иниц. - необязательный инициализатор, определяющий начальное значение соответствующего объекта.

Синтаксис инициализатора (иниц.) переменной:

= инициализирующее _выражение

либо

(инициализирующее_выражение)

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

  •  описывает переменную;
  •  содержит инициализатор;
  •  полностью описывает функцию (включает тело функции);
  •  описывает объединение или структуру (включая компоненты);
  •  описывает класс (включая его компоненты).

Описание не может быть определением, если:

  •  описывает прототип функции;
  •  содержит спецификатор extern;
  •  описывает статический компонент класса;
  •  описывает имя класса;
  •  описывает имя типа, вводимого пользователем (typedef).

Приведем примеры описаний:

extern int g;                    // Внешняя переменная

float fn(int, double);           // Прототип функции 

extern const float pi;           // Внешняя константа 

struct st;                       // Имя структуры (класса)

typedef unsigned char simbol;    // Новый тип simbol.

Примеры определений:

char sm;                        // Переменная автоматической или внешней памяти

float dim = 10.0;               // Инициализированная переменная

double Euler(2.718282);         // Инициализированная переменная

                               // автоматической памяти

const float pi = 3.14159;       // Константа

float x2(float x) (return x*x;);// Функция 

struct (char a; int b;) st;     // Структура 

enum (zero, one, two);          // Перечисление

Некоторая неоднозначность есть в определении переменной char. Если такое описание находится в блоке, то это определение неинициализированной переменной автоматической памяти. Если такое описание появляется вне блоков и классов, то это определение статической внешней переменной, которой по умолчанию присваивается нулевое значение, а для символьной переменной char - пробел. В тех файлах, где нужен доступ к этой глобальной переменной, должно быть помещено ее описание вида extern char sm;. Подобное описание необходимо для доступа из функций к внешним объектам, определения которых размещены в том же файле, но ниже текста определения функции. Пример:

// РЗ-11.СРР - определения и описания переменных
#include < iostream.h >
float pi = 3.141593; // Определение с явной инициализацией
int s0;     // Определение s0 (инициализация по умолчанию)
int s2 = 5;    // Определение s2 с явной инициализацией
void main()
{ extern int s0; // Описание s0
extern char s1;   // Описание s1
int s2(4);      // Описание s2 с явной инициализацией
cout << "\n Инициализация по умолчанию: s0 = " << s0;
cout << "\n Явная инициализация: s1 = " << s1;
cout << "\n Внутренняя переменная: s2 = " << s2;
cout << "\n Внешняя переменная: pi = " << pi;
}
char s1='S'; // Определение s1 с явной инициализацией
//Конец файла с текстом программы

Результат выполнения программы:

Инициализация по умолчанию: s0 = 0
Явная инициализация: s1 = S
Внутренняя переменная: s2 = 4
Внешняя переменная: pi = 3.141593

Для инициализации переменной s2, относящейся к автоматической памяти, использована "скобочная" форма задания начального значения. Внешние переменные таким образом инициализировать нельзя - компилятор воспринимает их как неверные определения функций. В программе обратите внимание на переменную pi, которая определена (и инициализирована) вне функции main(), а внутри нее не описана. Так как программа оформлена в виде одного файла, то все внешние переменные, определенные до текста функции, доступны в ней без дополнительных описаний. Таким образом, описание extern int s0; в данной однофайловой программе излишнее, а описание extern char s1; необходимо.

Следующая программа еще раз иллюстрирует доступ к внешним переменным из разных функций однофайловой (одномодульной) программы:

//РЗ-12.СРР - обмен между функциями через внешние
//                     переменные
#include < iostream.h >
int x;                     // Определение глобальной переменной
void main ()
{ void func(void); // Необходимый прототип функции
extern int x;         // Излишнее описание
х = 4;
func ();
cout << "\n х = " << х;
}
void func (void)
{ extern int x;       // Излишее описание х = 2;
}

Результат выполнения:

х = 2

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

Появление typedef среди спецификаторов класса памяти (auto, static,...) объясняется не семантикой, а синтаксическими аналогиями. Служебное слово typedef специфицирует новый тип данных, который в дальнейшем можно использовать в описаниях и определениях. Однако не следует считать, что typedef вводит новый тип. Вводится только новое название (имя, синоним) типа, который программист хочет иметь возможность называть по-другому. Сравним два описания:

static unsigned int ui;
typedef unsigned int NAME;

В первом определена статическая целая беззнаковая переменная ui, a во втором никакой объект не определен, а описано новое имя типа NАМЕ для еще не существующих без знаковых целых объектов. В дальнейшем NAME можно использовать в описаниях и определениях. Например, запись

register NAME rn = 44; // Допустим спецификатор
                                     // класса памяти

эквивалентна определению

register unsigned int rn = 44;

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

long NAME start; // Ошибочное сочетание
                            // спецификаторов типов

Однако определение const NАМЕ cn = о; вполне допустимо. const - не имя типа, а модификатор.

Спецификатор typedef нельзя употреблять в определениях функций, однако, можно в их описаниях (прототипах).

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

Например:

typedef long NL;
unsigned int NL = 0; // Ошибка - повторное определение ML void func()
{ int NL = 1;            // Верно - новый объект определен
}

Модификаторы const и volatile. Эти модификаторы позволяют сообщить компилятору об изменчивости или постоянстве определяемого объекта. Если переменная описана как const, то она недоступна в других модулях программы, ее нельзя изменять во время выполнения программы. Единственная возможность присвоить ей значение - это инициализация при определении. Объекту с модификатором const не только нельзя присваивать значения, но для него запрещены операции инкремента (++) и декремента (- -). Указатель, определенный с модификатором const, нельзя изменять, однако может быть изменен объект, который им адресуется. Примеры с константами:

const zero = 0;                        //По умолчанию добавляется тип

int const char *ptrconst = "variable"; // Указатель

const                                  // на строку

char *point = "строка";                // Обычный указатель на строку

char const *ptr = "константа";         // Указатель на

                                      // строку-константу

char *varptr = ptr;                    // Запрещено

zero += 4;                             // Ошибка - нельзя изменить константу

ptrconst = point;                    // Ошибка - указатель должен быть

                                   // постоянным

strcpy(ptrconst,point);                // Допустимо - меняется адресуемая

                                      // строка

strcpy(ptrconst,ptr);                  // Допустимо, значения ptrconst                               

                                      // и ptr не изменяются

Отметим ошибочную попытку присвоить указателю (не константному) varptr значение указателя на константу. Это запрещено, так как в противном случае можно было бы через указатель varptr изменить константу.

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

Модификаторы const и volatile имеют особое значение при работе с классами, и мы к ним еще обратимся.

Кроме спецификаторов класса памяти и модификаторов const, volatile, диалекты языка Си++, реализованные в компиляторах для ПЭВМ типа IBM PC, включают модификаторы [9]:

  •  cdecl (для функции и переменных);
  •  pascal(для функций и переменных);
  •  interrupt (для функций обработки прерываний);
  •  near (для указателей, функций, переменных);
  •  far (для указателей, функций, переменных);
  •  huge (для указателей и функций).

Эти модификаторы предназначены для влияния на распределение памяти при размещении объектов и учета особенностей сегментной организации и адресации памяти в процессорах семейства 80х86.

3.4. Выражения и преобразования типов

Выражение - это последовательность операндов, разделителей и знаков операций, задающая вычисление.

Порядок применения операций к операндам определяется рангами (приоритетами) операций (см. табл. 2.4) и правилами группирования операций (их ассоциативностью). Для изменения порядка выполнения операций и их группирования используют разделители (круглые скобки). В общем случае унарные операции (ранг 2), условная операция (ранг 14) и операции присваивания (ранг 15) правоассоциативны, а остальные операции левоассоциативны (см. табл. 2.4). Таким образом, х = у = z означает х= (у = z),ax + у - z означает (х + у) - z.

Кроме формирования результирующего значения, вычисление выражения может вызвать побочные эффекты. Например, значением выражения z = 3, z + 2 будет 5, а в качестве побочного эффекта z примет значение 3. В результате вычисления х > 0? x - -: х будет получено значение х, а в качестве побочного эффекта положительное значение х будет уменьшено на 1.

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

Формальный синтаксис языка Си++ [2,5] предусматривает рекурсивное определение выражений. Рекурсивность синтаксических определений (не только для выражений) и широкие возможности конструирования новых типов делают попытки "однопроходного" изложения семантики сразу всего языка Си++ практически безнадежными. Поясним это, рассмотрев выражения.

Основным исходным элементом любого выражения является первичное выражение. К ним относятся:

  •  константа
  •  имя
  •  (выражение)
  •  ::идентификатор
  •  ::имя_ функции-операции
  •  ::квалифицированное_имя
  •  this
  •  псевдопеременная (реализация Си++ для ПЭВМ).

К константам относятся:

  •  целая константа
  •  символьная константа
  •  перечислимая константа
  •  вещественная константа
  •  строковая константа (строка)

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

имя

К именам относятся:

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

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

имя_функции-операции

вводится только в связи с расширением действия (с перегрузкой) операций. Механизм перегрузки, возможно, объяснить только после определения понятия класс.

имя_функции_приведения

функции приведения, или преобразующие функции являются компонентами классов, и для объяснения их семантики требуется ввести соответствующие понятия, относящиеся к классам.

имя_ класса

обозначает обращение к специальному компоненту класса - к деструктору.

квалифицированное_имя (уточненное_имя)

имеет рекурсивное определение следующего формата:

квалифицированное_имя_класса:: имя

Таким образом, чтобы определить понятие квалифицированиое_имя. Требуется ввести понятие квалифицированное_имя_класса и уже иметь определение имени. Следовательно, не вводя понятие "класс", можно в качестве имен из всего перечисленного использовать только идентификаторы.

(выражение)

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

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

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

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

  •  первичное_выражение
  •  постфиксное_выражение [выражение]
  •  постфиксное_выражение (список_выражвний)
  •  имя_простого_типа (списки_выражений)
  •  постфиксное выражение.имя
  •  постфиксное выражение->имя
  •  постфиксное выражение++
  •  постфиксное выражение- -

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

int j = 5, d[100]; // Определение целой переменной j
                // и массива d
...
...d[j]... // Постфиксное выражение
...

В записи PE[IE] постфиксное выражение (РЕ) должно быть, например, именем массива нужного типа, выражение IE в квадратных скобках должно быть целочисленного типа. Таким образом, если РЕ -указатель на массив, то РЕ [IE] - индексированный элемент этого массива. Примеры работы с индексированными переменными (с элементами массивов) уже несколько раз приводились. Например, индексирование анализировалось в связи с рассмотрением квадратных скобок в качестве бинарной операции в п.2.4.

Обращение к функции. Постфиксное выражение РЕ(список_выражений)

представляет обращение к функции. В этом случае РЕ - имя функции, или указатель на функцию, или ссылка на функцию. Список выражений - это список фактических параметров. Значения фактических параметров вычисляются до выполнения функции, поэтому и побочные эффекты проявляются до входа в нее и могут сказываться во время выполнения операторов тела функции. Порядок вычисления значений фактических параметров синтаксисом языка Си++ не определен. Например [2], функция f (int, int) при таком обращении

int m = 2;
f(m - -, m - -);

может быть вызвана в зависимости от реализации со следующими значениями параметров: f(2,2), или f(l,2), или f(2,l). (Компилятор ВС++ вызывает эту функцию в виде f(l,2).) В то же время за счет побочного эффекта значением переменной, а станет 0. Для проверки конкретных правил вычисления значений фактических параметров можете выполнить с помощью доступного компилятора такую программу:

//РЗ-14.СРР - порядок вычисления фактических параметров
#include < iostream.h >
int m = 5; // Глобальная переменная
void p(int i,int j,int k) // Определение функции
{ cout << "\ni = "<< i << " j = " << j << " k = " << k;
cout << "\nВнутри функции p(...) m=" << m;
}
void main ()
{ void p(int, int, int); // Прототип функции
р(m++, (m = m * 5, m * 3), m - -);
cout << "\nВ главной программе после вызова р(...)";
cout << " m = " << m;
}

Результат выполнения программы для компилятора в С++:

i = 20     j = 60      k=5
Внутри функции р (. . .)
m = 21
В главной программе после вызова p(...)
m = 21

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

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

Явное преобразование типа. Постфиксное выражение type(список_выражений) служит для формирования значений типа type на основе списка_выражений, помещенного в круглых скобках. Если выражений больше одного, то тип должен быть классом, и данное постфиксное выражение вызывает конструктор класса. Если в списке выражений всего одно выражение, a type - имя простого типа, то имеет место уже рассмотренное в разделе 2.4 непосредственное функциональное приведение типа. Общая форма такой функциональной записи явного Преобразования типа имеет вид: имя простого типа (выражение). Примеры: int (3.14), float (2/5), int (' А ').

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

unsigned long (x/3+2)

или

char *(0777)

как функциональные преобразования вызовет ошибку компиляции. Напомним (см. п. 2.4), что кроме функциональной записи для явного преобразования типа можно использовать каноническую операцию приведения (cast) к требуемому типу. Для ее изображения используется обозначение типа в круглых скобках. Те же примеры можно записать с помощью операции приведения типа так: (int) 3.14, (float) 2/5, (int) 'А'. Каноническая операция приведения к типу может применяться и для типов, имеющих сложные обозначения. Например, можно записать

(unsigned long)(x/3+2)

или

(char *)0777

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

Другую возможность явного преобразования для типов со сложным наименованием обеспечивает введение собственных обозначении типов с помощью typedef.

Например:

typedef unsigned long int ULI;
typedef char *PCH;

После введения пользователем таких простых имен типов можно применять функциональную запись преобразования типа: ULI (x/3+2) или PSH(0777). При преобразовании типов существуют некоторые ограничения. Но прежде чем остановиться на них, рассмотрим стандартные преобразования типов, выполняемые при необходимости по умолчанию.

Стандартные преобразования типов. При вычислении выражений некоторые операции требуют, чтобы операнды имели соответствующий тип, а если требования к типу не выполнены, принудительно вызывают выполнение нужных преобразований. Та же ситуация возникает при инициализации, когда тип инициализирующего выражения приводится к типу определяемого объекта. Напомним, что в языках Си и Си++ присваивание является бинарной операцией, поэтому сказанное относительно преобразования типов относится и ко всем формам присваивания. Правила преобразования в языке Си++ для основных типов полностью совпадают с правилами преобразований, стандартизованными в языке Си. Эти стандартные преобразования включают перевод "низших" типов в "высшие" в интересах точности представления и непротиворечивости данных [5, 12]. Среди преобразования типов в языке Си++ выделяют:

  •  преобразования операндов в арифметических выражениях;
  •  преобразования указателей;
  •  преобразования ссылок;
  •  преобразования указателей на компоненты классов.

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

Рассмотрим этапы (последовательность выполнения) преобразования операндов в арифметических выражениях.

1.Все короткие целые типы преобразуются в типы не меньшей длины в соответствии с табл. 3.2 (см. [5, 12]). Затем оба значения, участвующие в операции, принимают тип int или float либо double в соответствии со следующими правилами [2, 5, 12].

2. Если один из операндов имеет тип long double, то второй тоже будет преобразован в long double.

Таблица 3.2

Правила стандартных арифметических преобразований

Исходный тип

Преобразуется в

Правила преобразований

Char

Int

Расширение нулем или знаком в зависимости от умолчания для char

unsigned char

Int

Старший байт заполняется нулем

Signed char

Int

Расширение знаком

Short

Int

Сохраняется то же значение

unsigned short

unsigned int

Сохраняется то же значение

Enum

Int

Сохраняется то же значение

битовое поле

Int

Сохраняется то же значение

3. Если п.2 не выполняется и один из операндов есть double, другой приводится к типу double.

4. Если п.2 - 3 не выполняются и один из операндов имеет тип float, то второй приводится к типу float.

5. Если п.2 - 4 не выполняются (оба операнда целые) и один операнд long int, а другой unsigned int, то, если long int может представить все значения unsigned int, последний преобразуется к long int; иначе оба операнда преобразуются к unsigned long int.

6. Если п.2 - 5 не выполняются и один операнд есть long, другой преобразуется к long.

7. Если п.2 - 6 не выполнены и один операнд unsigned, то другой преобразуется к unsigned.

8. Если п.2 - 7 не выполнены, то оба операнда принадлежат типу int.

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

На рис. 3.2, взятом с некоторыми сокращениями из проекта стандарта языка Си++ [2], стрелками отмечены арифметические преобразования, гарантирующие сохранение точности и неизменность численного значения.

Рис.3.2. Последовательности арифметических преобразований типов, гарантирующие сохранение значимости

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

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

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

//РЗ-16.СРР - потери информации при преобразованиях типов
#include < iostream.h >
void main ()
{ long k = 123456789;
float g = (float) k;
cout << "\n k = " << k // Печатает: k = 123456789
cout << "\n g = " << g // Печатает: g = 1.234567e+08
k = (long)g;
cout << "\n k = " << k // Печатает: k = 123456792
g = (float)2.222222e+2
int m = (int)g;
cout << "\n g = " << g // Печатает: g = 222.222198
cout << "\n m = " << m // Печатает: m = 222
g = (float)m;
cout << "\n g = " << g // Печатает: g = 222
}

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

Глава 4. ОПЕРАТОРЫ ЯЗЫКА СИ++

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

4.1. Последовательно выполняемые операторы

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

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

//Р4-01.СРР - обращение к функции как оператор-выражение
#include < iostream.h >
void cod_char(char с)
{cout << "\n " << с << " = " << (unsigned int)c;
}
void main ()
{ void cod_char(char); // Прототип функции
cod char( 'A' ); // Оператор-выражение
cod__char('x'); // Оператор-выражение
}

Результат выполнения программы:

А = 65
х = 120

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

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

// Вычисляется факториал: 5! for (int i = 0, p=1; i < 5; i++, p * = i);

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

ABC: x = 4 + х * 3;

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

metka: int z = 0, d = 4; // Метка перед определением

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

{ int a;
char b = '0';
a = (int)b;
}
// Это блок
{ func (z + 1.0, 22); е=4 * х-1;}// Составной оператор
// Составной оператор с условным переходом к его окончанию:
{ i - -; if (i > k) go to MET; k++; MET:;} // Помечен пустой
                                                               // оператор

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

4.2. Операторы выбора

К операторам выбора, называемым операторами управления потоком выполнения программы, относят: условный оператор (if...else) и переключатель (switch). Каждый из них служит для выбора пути выполнения программы.

Синтаксис условного оператора:

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

Выражение должно быть скалярным и может иметь арифметический тип или тип указателя. Если оно не равно нулю (или не есть пустой указатель), то условие считается истинным и выполняется оператор_1. В противном случае выполняется оператор_2. В качестве операторов нельзя использовать описания и определения. Однако здесь могут быть составные операторы и блоки:

if (х > 0)
{ х = -х; f(x * 2);
}
else
{ int i = 2; х * = i; f(x);
}

При использовании блоков (т.е. составных операторов с определениями и описаниями) нельзя забывать о локализации определяемых в блоке объектов. Например, ошибочна будет последовательность:

if (j > 0)
( int i; i = 2 * j; )
else i = -j ;

так как переменная i локализована в блоке и не существует вне его.

Допустима сокращенная форма условного оператора, в которой отсутствует else и оператор_2. В этом случае при ложности (равенстве нулю) проверяемого условия никакие действия не выполняются:

if (а < 0)   а = -а;

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

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

if (х == 1)
if (у == 1)
cout << "х равно 1 и у равно 1";
else cout << "х не равно 1";

При х, равном 1, и у, равном 1, совершенно справедливо печатается фраза "х равно 1 и у равно 1". Однако фраза "х не равно 1" может быть напечатана только при х, равном 1, и при у, не равном 1, т.к. else относится к ближайшему if. Внешний условный оператор, где проверяется х==1, является сокращенным и в качестве оператора_1 включает полный условный оператор, в котором проверяется условие у==1. Таким образом, проверка этого условия выполняется только при х, равном 1! Простейшее правильное решение этой микро задачи можно получить, применив фигурные скобки, т.е. построив, составной оператор. Нужно фигурными скобками ограничить область действия внутреннего условного оператора, сделав его неполным. Тем самым внешний оператор превратится в полный условный:

if (х == 1)
{ if (у == 1)
cout << "х равно 1 и у равно 1";
}
else
cout << "х не равно 1";

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

int max3(int х, int у, int z)
{ if (
х < у)
if (y < z)
return z;
else
return y;
else
if (y < x)
return z;
else return x;
}

В тексте соответствие if и else показано с помощью отступов.

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

switch (переключающее_выражение)
{ case константное_выражение_1: операторы_1;
case константное_выражение_2: операторы_2;
. . .
case константное_выражение_n: операторы_n;
default: операторы;
}

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

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

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

Сами по себе метки case константное_выражение_j: и default:

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

//P4-02.CPP - названия нечетных целых цифр, не меньших заданной
#include < iostream.h >
void main ()
( int ic;
cout << "\nВведите любую десятичную цифру:";
cin << ic;
cout << '\n';
switch (ic)
{ case 0: case 1 cout << "один,";
case 2: case 3 cout << "три,";
case 4: case 5 cout << "пять,";
case 6: case 7 cout << "семь,";
case 8: case 9 cout << "девять.";
break; // Выход из переключателя
default cout << "Ошибка! Это не цифра!";
}
// Конец переключателя
}
// Конец программы

Результаты двух выполнении программы:

Введите любую десятичную цифру:
4 < Enter > пять, семь, девять
Введите любую десятичную цифру:
z < Enter > Ошибка! Это не цифра!

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

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

В переключателе могут находиться описания и определения объектов, т.е. составной оператор, входящий в переключатель, может быть блоком. В этом случае нужно избегать ошибок "перескакивания" через определения [2]:

switch (n)                   // Переключатель с ошибками
{ char d = ' D ';          // Никогда не обрабатывается
case 1: float f = 3.14; // Обрабатывается только
                                 // для n == 1
case 2:
...
if (int(d)! = int(f))
...
// Ошибка:
// d и (или) f не определены
...
}

4.3. Операторы цикла

Операторы цикла задают многократное исполнение операторов тела цикла. Определены три разных оператора цикла:

  •  цикл с предусловием:
    while (выражение-условие) тело цикла
  •  цикл с постусловием:
    do
    тело_цикла while (выражение-условие);
  •  итерационный цикл:
    for (инициалиэация_цикла;
    выражение-условие;
    список_выражении) тело_цикла

тело цикла не может быть описанием или определением. Это либо отдельный (в том числе пустой) оператор, который всегда завершается точкой с запятой, либо составной оператор, либо блок (заключаются в фигурные скобки). Выражение-условие - это во всех операторах скалярное выражение (чаще всего отношение или арифметическое выражение), определяющее условие продолжения выполнения операций (если его значение не равно нулю). Инициализация_цикла в цикле for всегда завершается точкой с запятой, т.е. отделяется этим разделителем от последующего выражения-условия, которое также завершается точкой с запятой. Даже при отсутствии инициализации_цикла и выражения-условия в цикле for символы "точка с запятой" всегда присутствуют. Список_выражений (в цикле for) - последовательность скалярных выражений, разделенных запятыми. Прекращение выполнения цикла возможно в следующих случаях:

  •  нулевое значение проверяемого выражения-условия;
  •  выполнение в теле цикла оператора передачи управления (break, go to, return) за пределы цикла.

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

Оператор while (оператор "повторять пока (истинно условие)") называется оператором цикла с предусловием. При входе в цикл вычисляется выражение-условие. Если его значение отлично от нуля, то выполняется тело_цикла. Затем вычисление выражения-условия и выполнение операторов тела_цикла повторяются последовательно, пока значение выражения-условия не станет равным 0. Оператором while удобно пользоваться для просмотра всевозможных последовательностей, если в конце каждой из них находится заранее известный признак. Например, по определению, строка есть последовательность символов типа char, в конце которой находится нулевой символ. Следующая функция подсчитывает длину строки, заданной в качестве параметра:

int length (char *stroka)
{ int len = 0;
while (*stroka++) len++;
return len;
}

Здесь выход из цикла - равенство нулю того элемента строки, который адресуется указателем stroka. (Обратите внимание на порядок вычисления проверяемого выражения. Вначале будет выбрано значение указателя stroka, затем оно будет использовано для доступа по адресу, выбранное значение будет значением выражения в скобках и затем значение указателя будет увеличено на 1.) В качестве проверяемого выражения-условия часто используются отношения. Например, следующая последовательность операторов вычисляет сумму квадратов первых к натуральных чисел (членов натурального ряда):

int i = О; // Счетчик
int s = 0; // Будущая сумма
while (i < К)
s += ++i * i; // Цикл вычисления суммы

Если в выражении-условии нужно сравнивать указатель с нулевым значением (с пустым указателем), то следующие три проверки эквивалентны:

while (point != NULL) ... while (point) ... while (point != 0) ...

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

while (1); // Бесконечный цикл с пустым
             // оператором в качестве тела

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

Оператор do (оператор "повторять") называется оператором цикла с постусловием. Он имеет следующий вид:

do
тело_цикла while (выражение-условие);

При входе в цикл do обязательно выполняется тело_цикла. Затем вычисляется выражение-условие и, если его выражение не равно 0, вновь выполняется тело_цикла. При обработке некоторых последовательностей применение цикла с постусловием оказывается удобнее, чем цикла с предусловием. Это бывает в тех случаях, когда обработку нужно заканчивать не до, а после появления концевого признака. Например, следующая функция переписывает заданную строку (указатель star) в другую, заранее подготовленную строку (nov):

void copy_str(char *star, char *nov)
{ do *nov = *star++;
while (*nov++);
}

Еще один вариант того же цикла с пустым телом_цикла:

do
;
while(*nov ++= *star++);

Даже если строка пустая, в ней (по определению строки) в конце присутствует признак '\0'. Именно его наличие проверяется после записи по адресу nov каждого очередного символа.

К выражению-условию требования те же, что и для цикла while с предусловием - оно должно изменяться при итерациях либо за счет операторов тела цикла, либо при вычислениях. Бесконечный цикл:

do
;
while (1);

Оператор итерационного цикла for имеет формат:

for (инициализация_цикла;
выражение-условие;
список выражений)
тело цикла

Здесь инициализация_цикла - последовательность определений (описаний) и выражений, разделяемых запятыми. Все выражения, входящие в инициализацию цикла, вычисляются только один раз при входе в цикл. Чаще всего здесь устанавливаются начальные значения счетчиков и параметров цикла. Выражение-условие такое же, как и в циклах while и do: если оно равно 0, то выполнение цикла прекращается. В случае отсутствия выражения-условия следующий за ним разделитель "точка с запятой" сохраняется. При отсутствии выражения-условия предполагается, что его значение всегда истинно. При отсутствии инициализации цикла соответствующая ему точка с запятой сохраняется. Выражения из списка_выражений вычисляются на каждой итерации после выполнения операторов тела цикла и до следующей проверки выражения-условия. Тело_цикла может быть блоком, отдельным оператором, составным оператором и пустым оператором.

Следующие операторы for иллюстрируют разные решения задачи суммирования квадратов первых к членов натурального ряда:

for (int i = 1, s = 0; i <= K; i++) s += i * i;
for (int i = 0, s = 0; i <= K; s += ++i * i);
for (int i = 0, s=0; i <=
К; ) s += ++i * i;
for (int i = 0, s=0; i <=
К; ) { int j; j = ++i; s += j * j;
}

Во втором операторе тело_цикла - пустой оператор. В третьем отсутствует список_выражений. Во всех операторах в инициализации_циклов определены (и инициализированы) целые переменные.

Итак, еще раз проследим последовательность выполнения итерационного цикла for. Определяются объекты и вычисляются выражения, включенные в инициализацию_цикла. Вычисляется выражение-условие. Если оно отлично от нуля, выполняются операторы тела_цикла. Затем вычисляются выражения из списка выражений, вновь вычисляется выражение-условие и проверяется его значение. Далее цепочка действий повторяется.

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

for( ; ;); // Бесконечный цикл
for( ; 1; ); // Бесконечный цикл

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

Например, следующая последовательность операторов

for (int i = 0; i < 3; i++)
cout << "\t" << i;
for ( ; i > 0; i- -)
cout << "\t" << i;

выводит на печать такие значения переменной i:

0       1       2       3       2       1

Если во второй цикл поместить еще одно определение той же переменной i, т.е.

for (int i = 0; i < 3; i++)
cout << "\t" << i;
for (int i = 3; i > 0; i - -)
//
Ошибка!!
cout << "\t" << i;

то получим сообщение компилятора об ошибке: "многократное определение переменной i".

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

//Р4-ОЗ.СРР - вложение циклов
#include < iostream.h >
void main(void)
{ for (int i = 0; i < 3; i++)
{ cout << "\n
До цикла: i = " << i;
cout << ",
вложенный цикл: ";
for (int i = 6; i > 3; i - -)
cout << " i = " << i;
cout << ".\n
После: i = " << i << ".";
}
}

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

До цикла: i = 0, вложенный цикл: i = 6    i = 5    i = 4.
После: i=3
До цикла: i = 1, вложенный цикл: i = 6    i = 5    i = 4.
После: i = 3
До цикла: i = 2, вложенный цикл: i = 6    i = 5    i = 4.
После: i = 3

До внутреннего цикла действует определение переменной i в инициализации внешнего цикла for. Инициализация внутреннего цикла определяет другую переменную с тем же именем, и это определение остается действительным до конца тела внешнего цикла.

4.4. Операторы передачи управления

К операторам передачи управления относят оператор безусловного перехода, иначе - оператор безусловной передачи управления (go to), оператор возврата из функции (return), оператор выхода из цикла или переключателя (break) и оператор перехода к следующей итерации цикла (continue).

Оператор безусловного перехода имеет вид:

Go to идентификатор;

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

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

gо to B;                    // Ошибочный переход, минуя описание
float х = 0.0;            // Инициализация не будет выполнена
go to B;                   // Допустимый переход, минуя блок
{ int n = 10;             // Внутри блока определена переменная
х = n * х + х;
}
В: соut << "\tx = " << х;

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

{ ... // Внешний блок
gо tо AВС; // Во внутренний блок, минуя описание ii
...
{ int ii = 15; // Внутренний блок
... AВС:
...
gо tо ХУZ; // Обход описания СС сhаг СС = ' ';
...
ХУZ: ...
}
...
}

Принятая в настоящее время дисциплина программирования рекомендует либо вовсе отказаться от оператора gо tо, либо свести его применение к минимуму и строго придерживаться следующих рекомендаций [16]:

  •  не входить внутрь блока извне;
  •  не входить внутрь условного оператора, т.е. не передавать управление операторам, размещенным после служебных слов if или еlsе;
  •  не входить извне внутрь переключателя (switch);
  •  не передавать управление внутрь цикла.

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

Оператор возврата из функции имеет вид:

return выражение;
или просто
return;

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

float cube(float х)       {return z * z * z;}

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

void cube_print (float z)
}
cout << "\t
сubе = " << z * z * z;
return;
}

В данном примере оператор возврата из функции не содержит выражения.

Оператор brеаk служит для принудительного выхода из цикла или переключателя. Определение "принудительный" подчеркивает безусловность перехода. Например, в случае цикла не проверяются и не учитываются условия дальнейшего продолжения итераций. Оператор break. прекращает выполнение оператора цикла или переключателя и осуществляет передачу управления (переход) к следующему за циклом или переключателем оператору. При этом в отличие от перехода с помощью goto оператор, к которому выполняется передача управления, не должен быть помечен. Оператор break нельзя использовать нигде, кроме циклов и переключателей.

Необходимость в использовании оператора brеаk в теле цикла возникает, когда условия продолжения итераций нужно проверять не в начале итерации (циклы fог, whi1е), не в конце итерации (цикл ), а в середине тела цикла. В этом случае тело цикла может иметь такую структуру:

{операторы
if (условие) brеаk;
операторы}

Например, если начальные значения целых переменных i, j таковы, что i < j, то следующий цикл определяет наименьшее целое, не меньшее их среднего арифметического:

while (i < j)
{i++;
if (i == j) br
еаk;
j - -;
}

Для i == 0, j == 3 результат i == j == 2 достигается при выходе из цикла с помощью оператора brеаk. (Запись i == j == 2 не в тексте программы означает равенство значений переменных х, j и константы 2.) Для i == о, j == 2 результат i == j == 1 будет получен при естественном завершении цикла.

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

//Р4-04.СРР - оператор brеаk в переключателе
#include < iostream. h >
void main ()
{ int iс;
cout << "\n Введите восьмеричную цифру: ";
cin >> iс;
соut << "\n" << iс;
switch (iс)
{ саsе 0: соut << " - нуль"; brеаk;
case 1: соut << " - один"; brеаk;
саsе 2: соut << " - два"; brеаk;
саsе 3: соut << " - три"; brеаk;
саsе 4: соut << " - четыре"; brеаk;
саsе 5: соut << " - пять"; brеаk;
саsе 6: соut << " - шесть"; brеаk;
саsе 7: соut << " - семь"; brеаk;
defaul t: соut << " - это не восьмеричная цифра!";
}
соut << "\nКонец выполнения программы.";
}

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

Циклы и переключатели могут быть многократно вложенными. Однако следует помнить, что оператор brеаk позволяет выйти только из самого внутреннего цикла или переключателя. Например, в следующей программе, которая в символьном массиве подсчитывает количество нулей (lс0) и единиц (0с1), в цикл вложен переключатель:

//Р4-05.СРР - brеаk при вложении переключателя в цикл
#include < iostream.h >
void main(void)
{ сhar с[] - "АВС100111";
int k0 = 0, k1 = 0;
fоr (int i = 0; с[i]!= '\0'; i++)
switch (с [i])
{ саsе '0': k0++; brеаk;
case '1': k1++; brеаk;
default: brеаk;
}
соut << "\nВ строкe " << k0 << "нуля," <<
k1 << "единицы";
}

Результат выполнения программы:

В строке 2 нуля, 4 единицы

Оператор brеаk в данном примере передает управление из переключателя, но не за пределы цикла. Цикл продолжается до естественного завершения.

При многократном вложении циклов и переключателей оператор brеаk не может вызвать передачу управления из самого внутреннего уровня непосредственно на самый внешний. Например, при решении задачи поиска в матрице хотя бы одного элемента с заданным значением удобнее всего пользоваться не оператором brеаk, а оператором безусловной передачи управления (gо tо):

...
fоr (int i = 0; i < n; i++)
fоr (int j = 0; j < m; j++)
{ if (А[i] [j] == х)
gо tо success;
// Действия при отсутствии элемента в матрицe
...
}
// Конец цикла suссеss:
соut << "\nЭлемeнт х найден. Строка i = " << i;
соut << ", столбец j = " << j;
...

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

...
fоr (i = О; i < n; i++) // Перебор строк матрицы
                                // Перебор элементов строки:
fоr (j = 0, р[1] = 1; j < m; j++)
if (А[i] [j] == 0.0)    // Обнаружен нулевой элемент
{ р[i] = 0.0;
brеаk;
}
еlsе р[i] *= А[i] [j];
...

При появлении в строке нулевого элемента оператор brеak прерывает выполнение только внутреннего цикла, однако внешний цикл перебора строк всегда выполнится для всех значений i от 0 до n = 1.

Оператор соntinuе употребляется только в операторах цикла. С его помощью завершается текущая итерация и начинается проверка условия дальнейшего продолжения цикла, т.е. условий начала следующей итерации. Для объяснений действия оператора соntinuе рекомендуется [2] рассматривать следующие три формы основных операторов цикла:

                  while  (fоо)         dо                  fоr (;fоо;)

                   { ...                { ...                { ...  

                  соntin:             соntiп:              соntin:

                     }                } whilе (fоо);          }

В каждой из форм многоточием обозначены операторы тела цикла.

Вслед за ними размещен пустой оператор с меткой соntin. Если среди операторов тела цикла есть оператор соntinuе, и он выполняется, то его действие эквивалентно оператору безусловного перехода на метку соntin.

Типичный пример использования оператора соntinue: подсчитать среднее значение только положительных элементов одномерного массива:

fоr (s = 0.0, k = 0, i = 0; i < n; i++)
{if (х[i] <= 0.0) соntinuе;
k++;                     // Количество положительных
s += х[i];              // Сумма положительных
}
if (k > 0) s = s/k; // Среднее значение

Глава 5. АДРЕСА, УКАЗАТЕЛИ, МАССИВЫ, ПАМЯТЬ

5.1. Указатели и адреса объектов

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

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

Указатели делятся на две категории - указатели на объекты и указатели на функции. Выделение этих двух категорий связано с отличиями в свойствах и правилах использования. Например, указатели ФУНКЦИЙ не допускают применения к ним арифметических операций, а указатели объектов разрешено использовать в некоторых арифметических выражениях. Начнем с указателей объектов.

В простейшем случае определение и описание указателя-переменной на некоторый объект имеют вид:

tуре *имя_ указателя;

где tуре _ обозначение типа; имя_указателя - это идентификатор; * - унитарная операция раскрытия ссылки (операция разыменования; операция обращения по адресу; операция доступа по адресу), операндом которой должен быть указатель (именно в соответствии с этим правилом вслед за ней следует имя_указателя).

Признаком указателя при лексическом разборе определения или описания служит символ '*', помещенный перед именем. Таким образом, при необходимости определить несколько указателей на объекты одного и того же типа этот символ '*' помещают перед каждым именем. Например, определение

int *i1р, *i2р, *iЗр, i;

вводит три указателя на объекты целого типа i1р, i2р, i3р и одну переменную i целого типа. Переменной i будет отведено в памяти 2 байта (ТС++ или ВС++), а указатели i1р, i2р, i3р разместятся в участках памяти, размер которых также зависит от реализации, но которые только иногда имеют длину 2 байта.

В совокупности имя типа и символ '*' перед именем воспринимаются как обозначение особого типа данных "указатель на объект данного типа".

При определении указателя в большинстве случаев целесообразно выполнить его инициализацию. Формат определения станет таким:

tуре *имя_ указателя инициализатор;

Как упоминалось, инициализатор имеет две формы записи, поэтому допустимы следующие две формы определения указателей:

tyре *имя_указателя = инициализирующее_выражение;
tурe *имя_указателя (инициализирующее_выражение);

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

  •  явно заданный адрес участка памяти;
  •  указатель, уже имеющий значение;
    выражение, позволяющее получить адрес объекта с помощью операции '&'.

Если значение константного выражения равно нулю, то это нулевое значение преобразуется к пустому (иначе нулевому) указателю. Синтаксис языка "гарантирует, что этот указатель отличен от указателя на любой объект" [2]. Кроме того, внутреннее (битовое) представление пустого указателя может отличаться от битового представления целого значения 0. В компиляторах ТС++ и ВС++ условное нулевое значение адреса, соответствующее значению пустого указателя, имеет специальное обозначение NULL. Примеры определений указателей:

сhаr cc = 'd';         // Символьная переменная (типа сhаr)
сhаr *рс = &cс;    // Инициализированный указатель на объект
                             // типа сhаr
сhаr *рtr(NULL); // Нулевой указатель на объект типа сhаr
сhаr *р;                // Неинициализированный указатель на
                            // объект типа сhаr

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

соut << "\n cc равно "<< cc <<" и *рс = "<< *рс;

будет выведено:

cc равно d. и *рс = d

Указатели рtr и р, определенные в нашем примере, пользуются различными "правами". Указатель рtr получил нулевое начальное значение (пустой указатель), и попытка его разыменования будет бесперспективной.

Не нужно надеяться, что пустой указатель связан с участком памяти, имеющим нулевой адрес или хранящим нулевое значение [2]. Синтаксис языка Си++ этого не гарантирует. Однако, присвоив затем рtг значение адреса уже существующего объекта, можно осмысленно применять операцию разыменования.

Например, любой из операторов присваивания

рtr = &cс;

или

рtr = рс;

свяжет рtr с участком памяти, выделенным для переменной cc, т.е. после их выполнения значением *рtr будет d'.

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

*рс = '+';

или

рtr = рс;
 *рtr = '+';

сделают значением переменной cc символ ' + '.

Унарное выражение *указатель обладает в некотором смысле правами имени переменной, т.е. *рс и *рtr служат синонимами (псевдонимами, другими именами) имени cc. Выражение *указатель может использоваться практически везде, где допустимо использование имен объектов того типа, к которому относится указатель. Однако это утверждение справедливо лишь в том случае, если указатель инициализирован при определении явным способом. В нашем примере не инициализирован указатель р. Поэтому попытки использовать выражение *р в левой части оператора присваивания или в операторе ввода неправомерны. Значение указателя р неизвестно, а результат занесения значения в неопределенный участок памяти непредсказуем и иногда может привести к аварийному событию.

*р = '%'; // Ошибочное применение неинициализированного р

Если присвоить указателю адрес конкретного объекта (р = &cс;) или значение уже инициализированного указателя (р = рс;), то это превратит *р в синоним (псевдоним) уже имеющегося имени объекта.

Чтобы связать неинициализированный указатель с новым участком памяти, еще не занятым никаким объектом программы, используется оператор new или присваивается указателю явный адрес:

р = nеw сhаr; // Выделили память для переменной типа сhаr
                      // и связали указатель р с этим участком
                      // памяти
р = (сhаr *)Охb8000000; // Начальный адрес видеопамяти
                                         // ПЭВМ для цветного дисплея
                                         // в текстовом режиме

Обратите внимание на необходимость преобразования числового значения к типу указателя (сhаr *).

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

*р ='&';

или

сin >> *р;

Числовое значение адреса может быть использовано не только во время присваивания указателю значения в ходе выполнения программы, но и при инициализации указателя при его определении. Нужно только не забывать о необходимости явного преобразования типов. Например, в следующем определении указатель с именем Соmputеr при инициализации получает значение адреса того байта, в котором содержатся сведения о типе компьютера, на котором выполняется программа (справедливо только для ЭВМ, совместимых с IВМ РС):

сhаr *Сomputer = (сhаr *) ОхFОООFFFE;

Сведения об адресах таких "знаменитых" участков памяти ПЭВМ, как байт идентификации типа ПЭВМ или байты состояния клавиатуры или адрес страницы видеопамяти, можно получить, например, из книги Р.Джордейна.

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

В соответствии с соглашениями, принятыми в операционной системе М5-D0S, байт основной памяти, имеющий шестнадцатеричный адрес ОхFOOOFFFE, может содержать следующие коды:

  •  FF (для 1ВМ РС);
  •  FE (для 1ВМ РС ХТ);
  •  FD (для 1ВМ РС jг);
  •  FА (для1ВМ РС АТ).

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

//Р5-01.СРР - проверка типа компьютера (обращение к байту // памяти)
#include < iоstream.h >
void main (void)
{ сhаr *Соmputer = (сhаr *) ОхРОООFFFЕ;
соut << "\nПрограмма выполняется на ";
switch (*Соmрutег)
{ саsе (сhаr) ОхFF: соut << "ПЭВМ типа IВМ РС.";
breаk;
саse (сhаr) ОхFЕ: соut << "ПЭВМ типа IВМ РС ХТ.";
break;
саse (сhаr) ОхFD: соut << "ПЭВМ типа IВМ РСjr.";
breаk;
саse (сhаr) ОхFС: соut << "ПЭВМ типа IВМ РС АТ.";
breаk;
default: соut << "ПЭВМ неизвестного типа.";
}
}

Результат выполнения на ПЭВМ с процессором 80386 при использовании модели памяти Largе:

Программа выполняется на ПЭВМ типа IВМ РС АТ.

В тексте программы обратите внимание на явные преобразования типов. Во-первых, целочисленный шестнадцатеричный код адреса преобразуется к типу сhаr * определяемого указателя Computer. Значением *Соmputer служит величина типа chаr, поэтому в метках переключателя после саsе также должны быть значения типа сhаr. Явные преобразования типов (спаr) помещены перед шестнадцатеричными кодами.

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

const:
tурe сопst * соnst имя_указателя инициализатор;

Модификаторы соnst - это необязательные элементы определения. Ближайший к имени указателя модификатор соnst относится собственно к указателю, а соnst перед символом '*' определяет "константность" начального значения, связанного с указателем. Мнемоника очевидна, так как выражение *имя_указателя есть обращение к содержимому соответствующего указателю участка памяти. Таким образом, определение неизменяемого (константного) указателя имеет следующий формат:

tурe * соnst имя_ указателя инициализатор;

Для примера определим указатель-константу keу_bуte и свяжем его с байтом, отображающим текущее состояние клавиатуры ПЭВМ IВМРС:

сhаr * соnst keу bуte = (сhаr *)1047;

Значение указателя keу_byte невозможно изменить, он всегда указывает на байт с адресом 1047 (шестнадцатеричное представление 0х0417). Это так называемый основной байт состояния клавиатуры.

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

//Р5-02.СРР - указатель-константа на байт состояния
//                клавиатуры #include < iostream.h >
void main (void)
{ char * const key_byte = ((char *)0х0417);
cout <<"\nБайт состояния клавиатуры:" << *key byte;
*key _byte = 'Ё';
cout << "\nБайт состояния клавиатуры:" << *key byte;
}

В нормальном состоянии клавиатуры, когда отключены режимы Caps Lock, Num Lock, Scroll Lock и включен режим вставки (Insert), результат выполнения программы, оттранслированной с моделью памяти Large, будет таким:

Байт состояния клавиатуры: А
Байт состояния клавиатуры: Ё

Кроме того, присваивание *key_byte = 'Е'; проявляется не только в изменении кода в байте с адресом 1047, но и во внешнем изменении состояния клавиатуры. Контрольные лампочки регистров Caps Lock, Num Lock, Scroll Lock загораются, и для возврата клавиатуры в нормальное состояние необходимо нажать соответствующие клавиши переключения регистров. Обратите внимание на использованный инициализатор указателя key_byte.

Его формат для примера взят другим, нежели в Р5-01.СРР для определения начального значения указателя Computer.

Итак, содержимое участка памяти, на который "смотрит" указатель-константа, можно явно изменять. Попытку изменить значение самого указателя-константы, т.е. операцию вида

Key _byte = NULL;

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

Error ...: Can not modify a const object.

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

type const * имя_указателя инициализатор;

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

const int zero = 0;                             // Определение константы
int const *point_ to_ const = &zero; // Указатель на
                                                        // константу 0

Операторы вида

*point_to_const = 1;
 cin " *point to const;

недопустимы, так как каждый из них - это попытка изменить значение константы 0. Однако операторы

point_to_const = &CC;
point to const = NULL;

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

Можно определить неизменяемый (постоянный) указатель на константу. Например, иногда полезен так определенный указатель-константа на константное значение:

const float pi = 3.141593;
        float const *const pointpi == & pi;

Здесь невозможно изменить значение константы, обращаясь к ней с помощью выражения *pointpi. Нельзя изменить и значение указателя pointpi, т.е. он всегда "смотрит" на константу 3.141593.

Работая с указателями, постоянно используют операцию в - получение адреса объекта. Для нее существуют естественные ограничения:

  •  Нельзя определять адрес неименованной константы, т.е. недопустимы выражения &3. 141593 или & '?'.
  •  Нельзя определять адрес значения, получаемого при вычислении скалярных выражений, т.е. недопустимы конструкции
    &(44 * х - z) ИЛИ &(а + b)!= 12
  •  Нельзя определить адрес переменной, относящейся к классу памяти register. Следовательно, ошибочной будет последовательность операторов:
    int register Numb = 1;
    int *prt_Numb = &Numb;

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

Однако допустимо получать адрес именованной константы, т.е. правомерна, например, такая последовательность определений:

const float Euler = 2.718282;
   float *pEuler = (float *)&Euler;

(Обратите внимание на необходимость явного приведения типов, так как &Euler имеет тип const float *a не float *.)

5.2. Адресная арифметика, типы указателей и операции над ними

Во многих языках, предшествовавших языкам Си и Си++, например в ПЛ/1, указатель относился к самостоятельному типу указателей, который не зависел от существования в языке других типов. Для расширения возможностей адресной арифметики в языках Си и Си++ каждый указатель связан с некоторым типом.

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

Основные типы, как обычно, определяются ключевыми словами:

char, int, float, long, double, short, unsigned, signed, and void.

Примеры указателей, относящихся к основным типам char, int и float, уже рассматривались. Вот несколько других определений:

long double ld = 0.0; // ld - переменная long double
*ldptr = &ld;            // ldptr - указатель
void *vptr;                // vptr - указатель типа void *
unsigned char *cucptr; // cucptr - указатель без
                                   // начального значения
unsigned long int *uliptr = NULL // uliptr - указатель...

Если операция & получения адреса объекта всегда дает однозначный результат, который зависит от размещения объекта в памяти, то операция разыменования *указатель зависит не только от значения указателя, но и от его типа. Дело в том, что при доступе к памяти с помощью разыменования указателя требуется информация не только о размещении, но и о размерах участка памяти, который будет использоваться. Эту дополнительную информацию компилятор получает из типа указателя. Указатель char *cp; при обращении к памяти "работает" с участком в 1 байт

Указатель long double *ldp; будет "доставать" данные из 10 смежных байт памяти и т.д. Иллюстрирует сказанное следующая программа, где указателям разных типов присваиваются значения адреса одного участка памяти:

//Р5-ОЗ. СРР - выбор данных из памяти с помощью разных
//                       указателей
#include < iostream.h >
void main ()
{ unsigned long L = Oxl2345678L;
char *cp = (char *)&L; // *cp равно 0х78
int *ip = (int *)&L;        // *ip равно 0х5678
long *lp = (long *>&L; // *lp равно 0х12345678
cout " hex;           // Шестнадцатеричное представление
                          // выводимых значений
cout << "\nАдрес L, т.е. &L = " << &L;
cout << "\nср = " << (void *) cp << "\t*cp = Ox" << (int)*cp;
cout << "\nip = " << (void *)ip << "\t*ip = Ox" << *ip;
cout << "\nlp = " << (void *) lp << "\t*lp = Ox" << *lp;
}

Результат выполнения программы:

Адрес L, т.е. &L = OxlE190FFC
cp = OxlE190FFC *ср = 0х78
ip = OxlE190PFC *ip = 0х5678
lp = Ox1E19OFFC *lp = 0х12345678

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

В программе потребовалось явное приведение типов. Так как адрес &L имеет тип unsigned long *, то при инициализации указателей его значение явно преобразуется соответственно к типам char *, int *, long *. При выводе значений указателей они преобразуются к типу void *, ибо нас не интересуют длины участков памяти, связанных со значениями указателей.

В программе при выводе результатов в поток cout (по умолчанию он связан с экраном дисплея) использован новый тип нашего изложения элемент - манипулятор hex форматирования выводимого значения. Этот манипулятор hex обеспечивает вывод числовых кодов в шестнадцатеричном виде (в шестнадцатеричной системе счисления). Подробнее о форматировании вводимых и выводимых данных будем говорить при описании потоков ввода-вывода, которые не описаны в проекте стандарта [2] и полностью зависят от реализации. Программы этой главы выполнялись с помощью компилятора ВС++ версии 3.1.

При выводе значения *ср использовано явное преобразование типа (int), так как при его отсутствии будет выведен не код (= 0х78), а соответствующий ему символ 'х' ASCII-кода. Еще один неочевидный результат выполнения программы связан с аппаратными особенностями ПЭВМ IBM PC - размещение числовых кодов в памяти, начиная с младшего адреса. За счет этого пары младших разрядов шестнадцатеричного числового кода размещаются в байтах памяти с меньшими адресами. Именно поэтому *ip равно 0х5678, а не 0х1234, и *ср равно 0х78, а не 0х12. Сказанное иллюстрирует рис. 5.1.

Рис. 5.1. Схема размещения в памяти ПЭВМ IBM PC переменной L типа unsigned long для программы Р5-03. СРР (младшие разряды числа в байте с меньшим адресом)

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

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

//Р5-04.СРР - неявное приведение типа void * к стандартным
//                     типам
#include < iostream.h >
void main ()
{ void *vp;
int i = 77;
float Euler = 2.718282;
cout << "\nНачальное значение vp = " << vp;
vp = &i; // "Настроились" на int
cout << "\nvp - " << vp <<
"\t*(int *) vp >> " << *(int *) vp;
vp = fEuler; // " Настроились" на float
cout << "\nvp = " << vp "
"\t* (float *) vp = " << * (float *) vp;
}

Результат выполнения программы:

Начальное значение vp = 0х2E030000
vp = Ox8D8FOFFA *(int *)vp = 77
vp = Ox8D8FOFF6 *(float *) vp = 2.718282

Возможность связывать указатели типа void * с объектами разных типов эффективно используется в "родовом программировании" на языке Си. Основная идея "родового программирования" состоит в том, что программа или отдельные функции создаются таким образом, чтобы они могли работать с максимальным количеством типов данных. Именно поэтому указатели типа void * называются родовыми (generic) указателями [9].

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

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

Разрешено неявное (умалчиваемое) преобразование значения любого не константного и не имеющего модификатора volatile указателя к указателю типа void *. Никакие другие преобразования типов указателей по умолчанию не выполняются. Например, в предыдущей программе неверной будет такая последовательность операторов:

void *vp; int *ip; ip = vp;

Транслятор сразу же выводит сообщение об ошибке:

Error ...: Cannot convert 'void *' to 'int *'

Такой запрет на преобразование типа void * к другим типам объясняется недопустимостью ситуации, когда к одному и тому же объекту будет доступ с помощью указателей разных типов. Например, в той же программе Р5-04. СРР последовательность операторов vp = &Euler; int *ip; ip = vp; позволила бы обращаться к значению типа float (2.718282) с помощью *ip.

Приблизительно по тем же причинам не все будет допустимо в следующих операторах.

int i; int *ip = NULL;
...
void *vp; char *cp;
...
vp = i? vp : cp; // Допустимый оператор
vp = ip? ip: cp; // Ошибка в выражении: операнды должны
                        // иметь одинаковый тип

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

Операции над указателями можно сгруппировать таким образом:

  •  операция разыменования или доступа по адресу (*);
  •  преобразование типов (приведение типов);
  •  присваивание;
  •  получение (взятие) адреса (б);
  •  сложение и вычитание (аддитивные операции);
  •  
  •  инкремент или авто увеличение (++);
  •  декремент или авто уменьшение (- -);
  •  операции отношений (операции сравнения).

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

unsigned int *uip1 = NULL, *uip2;
uip2 = (unsigned int *)&uip1;

Здесь описаны два указателя, первый из которых uip1 получает нулевое значение при инициализации, а второму uip2 в качестве значения присваивается адрес указателя ui.pl. Обратите внимание на явное преобразование типа в операторе присваивания. При его отсутствии, т.е. для оператора uip2 - &uipl; будет выдаваться сообщение об ошибке.

Начинать изучение аддитивных операций удобнее с вычитания. Вычитание применимо к указателям на объекты одного типа и к указателю и целой константе. Вычитая два указателя одного типа, можно определять "расстояние" между двумя участками памяти. "Расстояние" определяется в единицах, кратных длине (в байтах) объекта того типа, к которому отнесен указатель. Таким образом, разность однотипных указателей, адресующих два смежных объекта любого типа, по абсолютной величине всегда равна 1. Сказанное иллюстрирует следующая программа:

//Р5-05.СРР - вычитание указателей
#include < iostream.h >
void main ()
{
char ас = 'f',   bc = '2';
char *pac = &ac,   *pbc = &bc;
long int al = 3,   bl = 4;
long int *pal = &al,   *pb1 = &b1;
cout << "\n3начения и разности указателей: ";
cout << "\npac = " << (void *) pac <<                  "\tpbc = " << (void *) pbc;
cout << "\t (pac - pbc) = "<< рас - pbc;
cout << "\npa1 = " << pa1 << "\tpb1 = " << pb1 <<
             "\t(pbl - pal) = " (pbl - pal);
cout << "\nРазности числовых значений указателей:"
cout << "\n (int) pac - (int) pbc = " <<
              (int)рас - (int)pbc;
cout << "\n (int) pbl - (int) pal = " <<
(int)pbl - (int)pal;
}

Результаты выполнения программы:

Значения и разности указателей:
рас = Oxle240fff pbc = Oxle240ffe (рас - pbc) = 1
pal = Oxlе240ff2 pb1 = Oxle240fee (pb1 - pa1) = -1
Разности числовых значений указателей:
(int) pac - (int) pbc = 1
(int) pbl - (int) pal = -4

Анализируя результаты, нужно обратить внимание на два важных факта. Первый относится собственно к языку Си++ (или к Си). Он подтверждает различие между разностью однотипных указателей и разностью числовых значений этих указателей. Хотя (int)pac -(int)pbc равно 1, a (int)pbl - (int)pal равно 4, разности соответствующих указателей в обоих случаях по абсолютной величине равны 1.

Второй факт относится не к самому языку Си++, а к реализации. В соответствии с интуитивным представлением о механизме распределения памяти те переменные, определения которых помещены в программе рядом, размещаются в смежных участках памяти. Это (см. Р5-05.СРР) видно из значений связанных с ними указателей (адресов). Однако совершенно неочевиден тот факт, что переменная, определенная в тексте программы позже, имеет меньший адрес, чем предшествующие ей в тексте программы объекты. Именно поэтому разности рас - pbc и (int) рас - (int)pbc равны 1, а разности pbl - pal и (int)pbl - (int) pal отрицательны.

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

Еще один пример иллюстрирует правила вычитания указателей и их отличия от вычитания численных значений адресов:

//Р5-06.СРР - вычитание адресов и указателей разных типов
#include < ioatream.h >
void main ()
{ double aa = 0.0, bb = 1.0;
double *pda = &aa, *pdb = &bb;
float *pfa = (float *)&aa, *pfb = (float *)&bb;
int *pia = (int *)&aa, *pib = (int *)&bb;
char *pca = (char *)&aa, *pcb = (char *)&bb;
cout << "\nАдреса объектов: &aa = " << &aa << "\t&bb >> " << &bb;
cout << "\nРазность адресов: (&bb - &aa) = " << (&bb - &aa);
cout << "\nРазность значений адресов: " << "((int)&bb - (int)&aa) = " << ((int)&bb - (int)&aa);
cout << "\nРазности указателей:";
cout << "\n double *: (pdb - pda)=" << (pdb - pda);
cout << "\n float *: (pfb - pfa)=" << (ptb - pfa);
cout << "\n int *: (pib - pia)=" << (pib - pia);
cout << "\n char *: (pcb - pca)=" << (pcb - pca);
}

Результат выполнения программы:

Адреса объектов: &аa = Ox21e90ff8 &bb - Ox21e90ff0
Разность адресов: (&bb - &aa) = -1
Разность значений адресов: ((int)&bb - (int)&aa) = -8
Разности указателей:
double *: (pdb - pda) = -1
float *: (pfb - pfa) = -2
int *: (pib - pia) = -4
char *: (pcb - pca) = -8

Из результатов видно, что определенные последовательно объекты aa и bb, имея тип double, размещаются в памяти рядом на "расстоянии" 8 байт. Однако разность адресов (&aa - &bb) равна 1. Это подтверждает тот факт, что значение, получаемое с помощью операции &имя_объекта, имеет права указателя того типа, к которому принадлежит объект. Остальные результаты очевидны - разности указателей вычисляются в единицах, кратных длине участка памяти для соответствующего типа данных.

Из указателя можно вычитать целочисленные значения. При этом числовое значение указателя уменьшается на величину

k * sizeof(type)

где k - "вычитаемое", type - тип объекта, к которому отнесен указатель.

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

//Р5-07.СРР - увеличение указателя
#include < iostream.h >
void main ()
{ float zero = 0.0, pi = 3.141593, Euler = 2.718282;
float *ptr = &Euler;
cout << "\nptr = " << ptr << " *ptr = " << *ptr;
cout << "\n (ptr +1) = " << (ptr + 1) <<
            " *(ptr +1) = " << *(ptr+l);
cout << "\n (ptr +2) = " << (ptr + 2) <<
            " * (ptr + 2) = " << * (ptr + 2);
}

Результат выполнения программы:

ptr = Ox22510ff4 *ptr = 2.718282
(ptr + 1) = Ox22510ff8 *(ptr + 1) = 3.141593
(ptr + 2) = Ox22510ffc *(ptr + 2) = О

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

Декремент (авто уменьшение) указателей (унарная операция --) и инкремент (авто увеличение) указателей (унарная операция ++) не имеют никаких новых особенностей. Как и вычитание единичной константы, операция - изменяет конкретное численное значение указателя типа type на величину sizeof (type), где type * - тип указателя. Тем самым указатель "перемещается" к соседнему объекту с меньшим адресом. Аналогично и действие операции единичного приращения ++. В зависимости от положения (до операнда-указателя или после него) выполнение унарных операций ++ и -- осуществляется либо до, либо после использования значения указателя. Но это обычное свойство инкрементных и декрементных операций, которое не связано с особенностями указателей.

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

//Р5-08.СРР - изменение указателя на произвольную величину
#include < iostream.h >
void main()
{long L1 = 12345678; int i = 6;
double d = 66.6; long L2 = 87654321;
cout << "\nHe кратные для long адреса: &L1 = " << &L1 <<
             " &L2 = " << &L2;
cout << "\n Разность некратных адресов: &L1 - &L2 = " <<
             &L1- &L2;
cout << "\n(&L2 + 3) = " << (&L2 +3);
int *pi;
long *p1 = &L1;
cout << "\np1 = " << p1 << " *p1 = " << *p1;
// Явно "переместим" указатель:
p1 = (long *)((long)&L1 - sizeof(int) -
sizeof(double) - sizeof(long));
cout << "\npl = " << p1 << " *p1 = " << *p1;
// Сформируем значение int * исходя из long *
pi = (int *)((long)&L2 + sizeof(long) + sizeof (double));
cout << "\npi = " << pi << " *pi >> " << *pi;
}

Результаты выполнения:

He кратные для long адреса:
&L1 = Ox8d890ffc &L2 = Ox8d890fee
Разность некратных адресов: &L1 - &L2 = 3
(&L2 + 3) = Ox8d890ffa
p1 = Ox8d890ffc *pl = 12345678
p1 = Ox8d890fee *pl = 87654321
p1 = Ox8d890ffa *pi = 6

Переменные LI и L2, имея шестнадцатеричные адреса ...ffc и ... fee, отстоят в памяти друг от друга на 14 (десятичное число) байт. Длина переменной типа long 4 байта. Таким образом, "расстояние" между LI и L2 не кратно длине переменной типа long. Разность &LI - &L2 равна 3, т.е. округленному значению выражения ((long)&Ll - (long) &L2))/ sixeof. Добавив эту величину к адресу &L2, получили значение ...ffa, не совпадающее с адресом SLI. Остальные результаты иллюстрируют особенности явного "перемещения" по байтам памяти. Обратите внимание на необходимость приведения типов (long *), (int *) в операциях присваивания и (long) при получении численных значений адресов, используемых в выражениях.

Рис. 5.2. Схема памяти ПЭВМ IBM PC переменных программы Р5-08.СРР

Указатели, связанные с однобайтовыми данными символьного типа, при изменении на 1 меняют свое "внутреннее" числовое значение именно на 1. Поэтому изменять имеющееся значение адреса (текущее значение указателя) на произвольное количество единиц (байтов) можно с помощью вспомогательного указателя p1 от переменной L1 к переменной L2 можно еще таким способом (см. рис. 5.2):

p1 = &L1;
char *рс = (char *) pl; // указывает на начало L1
рс = рс - 14; //рс - указывает на начало L2
рl = (long *)pc; // *pl - содержимое переменной L2

Еще раз обратим внимание на особенность вычитания указателей в тех случаях, когда они адресуют объекты, размещенные в памяти на расстоянии, не кратном одного объекта. Как уже отмечено выше, вычитание двух указателей type *p1, *p2; как бы ни были определены их значения, выполняется в соответствии с соотношением:

р1 - р2 == ((long)pl - (long)p2)) / sizeof(type)

В данном выражении у операции деления операнды целочисленные, поэтому результат округляется до целого отбрасыванием дробной части, если значения р1 и р2 не кратны sizeof (type).

Обладая правами объекта (как именованного участка памяти), указатель имеет адрес, длину и значение. О значениях указателей мы поговорили, следующая программа печатает значения адресов и длин некоторых типов указателей:

//Р5-09.СРР - адреса и длины указателей разных типов
#include < iоstrеаm.h >
void main ()
{ char *pac, *pbc;
long *pa1, *pb1;
cout << "\пАдреса указателей: ";
cout << "\n &pac = " << &рас << " &pbc = "<< &pbc;
cout << "\n &pal = " << &pa1 << " &pb1 = "<< &pb1;
cout << "\nДлины указателей некоторых типов: <<;
cout << "\n sizeof (void *) = " << sizeof (void *);
cout << "\n sizeof (char *) = " << sizeof (char *);
cout << "\n sizeof (int *) = " << sizeof (int *);
cout << "\n sizeof (long *) = " << sizeof (long *);
cout << "\n sizeof (float *) = " << sizeof (float *);
cout << "\n sizeof (double *) = " << sizeof (double *);
cout << "\n sizeof (long double *) = " <<
             sizeof (long double *);
}

Результат выполнения программы:

Адреса указателей:
&рас = Ox8d890ffc &pbc = Oxd890ff8
&pa1 = Ox8d890ff4 &pbl = Oxd890ff0
Длины указателей некоторых типов:
sizeof (void *) = 4
sizeof (char *) = 4
sizeof (int *) = 4
sizeof (long *) = 4
sizeof (float *) = 4
sizeof (double *) = 4
sizeof (long double *) = 4

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

//Р5-10.СРР - цепочка указателей на указатели
#include < iostream.h >
void main()
{ int i = 88;
int *pi = &i;
int **ppi = π
int ***pppi = &ppi;
cout << "\n ***pppi = " << ***pppi;
}

Результат выполнения программы:

***pppi = 88

Напомним, что ассоциативность унарной операции разыменования справа налево, поэтому последовательно обеспечивается доступ к участку памяти с адресом pppi, затем к участку с адресом (*pppi) == ppi, затем к (*ppi) == pi, затем к (*pi) == i. С помощью скобок последовательность разыменований можно пояснить таким выражением (*(*(*pppi))).

Работая с адресами и указателями, нужно внимательно относиться к последовательности выполнения операций *, ++, --, &, так как они в выражениях могут употребляться в самых разнообразных сочетаниях.

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

//Р5-11.СРР - приоритеты унарных операций
#include < iostream.h >
void main()
{ int i1 = 10, i2 = 20, i3 = 30;
int *p = &i2;
// Значение i2:
cout << "\n*&i2 = " << *&i2;
// Значение i2 сначала увеличенное на 1:
cout << "\n*&++i2 = " << *&++i2;
// Значение i2:
cout<<"\n*p = " << *p;
// Значение i2, p увеличивается на 1:
cout << "\n*p++ = " << *p++;
// Значение il
cout << "\n*p = " << *p;
// Значение il сначала увеличенное на 1:
cout << "\n++*p = " << ++*p;
// Значение i2, сначала уменьшается р
cout << "\n*--р = " << *--р;
// Значение i3, сначала уменьшается р, затем полученное
// значение i3 увеличивается:
cout << "\n++*--р " = << ++*--р;
}

Результат выполнения программы:

*&i2 = 20
*&++i2 = 21
*р = 21
*p++ = 21
*p = 10
++**р = 11
*--р = 21
++*--р = 31

Результаты иллюстрируют "лево направленность" выполнения расположенных рядом унарных операций *, ++, --, &. Однако выражение *р++ вычисляется в таком порядке: вначале выполняется разыменование (обращение по адресу), и полученное значение (21) служит значением выражения в целом. Затем выполняется операция Р++, и значение указателя увеличивается на 1. Тем самым он "устанавливается" на переменную il. (Особенность реализации - уже упомянутый "обратный" порядок размещения в памяти переменных 11, i2, i3. Последовательно уменьшая на 1 значение р, переходим от il к участкам памяти, отведенным для i2 и i3.)

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

++&i2; // Ошибка: требуется 1-выражение
--&i2++; // Ошибка: требуется 1-выражение

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

5.3. Массивы и указатели

В предыдущих главах уже определены и проиллюстрированы некоторые понятия, относящиеся к массивам. В первой главе программа для расчета должностных окладов содержит одномерный массив вещественного типа float для значений тарифных коэффициентов float а[] = {1.0, 1.3, ...}; (Список инициализации здесь приведен не полностью.)

Инициализация массива типа char[] значением строковой константы продемонстрирована в программах главы 2:

char имя_массива [] = "строковая константа>>;

(Напомним, что количество элементов в таком символьном массиве на 1 больше, чем количество символов в строковой константе, использованной для инициализации. Последний элемент массива в этом случае всегда равен '\0'.)

Несколько раз показано на примерах обращение к элементам одномерного массива с помощью индексирования. Отмечались роль разделителей [] (при описании и определении массивов) и существование в языке Си++ операции []. С помощью этой операции обеспечивается доступ к произвольному элементу массива по имени массива и индексу - целочисленному смещению от начала:

имя_массива [индекс]

Теперь необходимо тщательно разобрать соотношение между массивами и указателями.

Самое загадочное в массивах языков Си и Си++ - это их различное "поведение" на этапах определения и использования. При определении массива ему выделяется память так же, как массивам других алгоритмических языков (например, ПЛ/1 или Паскаль). Но как только память для массива выделена, имя массива воспринимается как константный указатель того типа, к которому отнесены элементы массива. Существуют исключения, например применение имени массива в операции sizeof. В этой операции массив "вспоминает" о своем отличии от обычного указателя, и результатом является размер в байтах участка памяти, выделенного не для указателя, а для массива в целом. Исключением является и применение операции & (получения адреса) к имени массива. Результат - адрес начального (с нулевым индексом) элемента массива. В остальных случаях значением имени массива является адрес первого элемента массива, и это значение невозможно изменить. Таким образом, для любого массива соблюдается равенство:

имя массива == &имя_массива == &имя_массива[0]

Итак, массив - это один из структурированных типов языка Си++. От других структурированных данных массив отличается тем, что все его элементы имеют один и тот же тип и что элементы массива расположены в памяти подряд. Определение одномерного массива типа type:

type имя_ массива [константное_выражение];

Здесь имя массива - идентификатор; константное_выражение, если оно присутствует, определяет размер массива, т.е. количество элементов в массиве. В некоторых случаях допустимо описание массива без указания количества его элементов, т.е. без константного выражения в квадратных скобках.

Например:

extern unsigned long UL [];

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

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

void f (void)
{ static float F[4];    // Внутренний статический массив
long double A[10]; // Массив автоматической памяти
}
void main()
{ extern int D[]; // Описание массива
...
f ();
...
}
int D[8]; // Внешний массив (определение)

Массивы D [8] и F[4] инициализированы нулевыми значениями. В основной программе main () массив D описан без указания количества его элементов. Массив А[10] не получает конкретных значений своих элементов при определении.

Явная инициализация элементов массива разрешена только при его определении и возможна двумя способами: либо с указанием размера массива в квадратных скобках, либо без явного указания (без конкретного выражения) в квадратных скобках:

char СН [] = {'А', 'В', 'С', 'D'}; // Массив из 4 элементов
int IN [6] = {10, 20, 30, 40}; // Массив из 6 элементов
char STR [] = "ABCD"; // Массив из 5 элементов

Количество элементов массива CH компилятор определяет по числу начальных значений в списке инициализации, помещенном в фигурных скобках при определении массива. В массиве IN шесть элементов, но только первые четыре из них явно получают начальные значения. Элементы IN[4], IN [5] либо не определены, либо имеют нулевые значения, когда массив внешний или статический. В массиве STR элемент STR[4] равен '\0', а всего в этом массиве 5 элементов.

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

float А []; // Ошибка в определении массива - нет размера
double В [4] = {1, 2, 3, 4, 5, 6}; // Ошибка
                                                   // инициализации

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

extern float E []; // Правильное описание внешнего массива

Предполагается, что в месте определения массива Е для него выделена память и выполнена инициализация.

Описание массива (без указания размера и без списка начальных значений) может использоваться в списке формальных параметров определения функции и в спецификации параметров прототипа функции. Примеры:

float MULTY (float G [], float F []) // Определение функции
                                                       // MULTY
{...
тело_функции
...
}
void print_array(int I[]); // Прототип функции print_array

Доступ к элементам массива с помощью индексированных переменных мы уже несколько раз демонстрировали на примерах. Приведем еще один, но предварительно обратим внимание на полезный прием, позволяющий контролировать диапазон изменения индекса массива при его "просмотре", например в цикле. С помощью операции sizeof(имя_ массива) можно определить размер массива в байтах, т.е. размеры участка памяти, выделенного для массива. Так как все элементы массива имеют одинаковые размеры, то частное sizeof(имя_ массива)/sizeof(имя_мaccивa[0]) определяет количество элементов в массиве. Следующий фрагмент программы печатает значения всех элементов массива:

int m[] = {10, 20, 30, 40};
for (int i = 0; i < sizeof (m)/sizeof (m [0]); i++)
cout << "m [<< i << "] = " << m [i] << " ";

Результат на экране дисплея:

M [O] = 10     m [1] = 20     m [2] = 30     m [3] = 40

Еще раз отметим, что для первого элемента массива индекс равен 0. Цикл завершается при достижении i значения 4.

По определению, имя массива является указателем-константой, значением которой служит адрес первого элемента массива (с индексом 0). Таким образом, в нашем примере &m == m. Раз имя массива есть указатель, то к нему применимы все правила адресной арифметики, связанной с указателями. Более того, запись

имя_масcива [индекс]

является выражением с двумя операндами. Первый из них, т.е. имя массива, - это константный указатель - адрес начала массива в основной памяти. Индекс - это выражение целого типа, определяющее смещение от начала массива. Используя операцию обращения по адресу * (раскрытие ссылки, разыменование), действие бинарной операции [] можно объяснить так:

* (имя_массива + индекс)

Таким образом, операндами для операции [] служат имя массива и индекс. В языках Си и Си++ принято, что индексы массивов начинаются с нуля, т.е. массив int z [3] из трех элементов включает индексированные элементы z[0],z[l],z[2]. Это соглашение языка становится очевидным, если учесть, что индекс определяет не номер элемента, а его смещение относительно начала массива. Таким образом, *z - обращение к первому элементу z [0], *< массива: элементами с работе при скобки квадратные использовать не можно как показано, программе следующей В т.д. и z элементу второму ко обращение - l) +>

//Р5-12.СРР - работа с элементами массива без скобок []
&include < iostream.h >
void main()
{ char x[] = "DIXI" ; // "Я сказал (высказался)"
int i = 0;
while (*(x + i)! = '\0')
cout << "\n" << * (х + i++);
}

Результат выполнения программы: слово "DIXI", написанное в столбик (CM.Р2-15.СРР).

В данном примере оператор цикла с заголовком white выполняется, пока верно выражение в скобках, т.е. пока очередной символ массива не равен ' \0 '. Это же условие можно проверять и при таком заголовке цикла:

while (*(х + i))

В цикле при каждом вычислении выражения х + i++ используется текущее значение i, которое затем увеличивается на 1. Тот же результат будет получен, если для вывода в цикле поместить оператор cout << '\n' << х [i++];. (Квадратные скобки играют роль бинарной операции, а операндами служат имя массива х и индекс i++.) Индексированный элемент можно употребить и в заголовке цикла:

while (x[i]).

Обращение к элементу массива в языке Си++ относят к постфиксному выражению вида PE[IE]. Постфиксное выражение РЖ должно быть указателем на нужный тип, выражение РЕ в квадратных скобках должно быть целочисленного типа. Таким образом, если РЕ - указатель на массив, то PE[IE] - индексированный элемент этого массива.

* (РЕ + IE) - другой путь доступа к тому же элементу массива. Поскольку сложение коммутативно, то возможна такая эквивалентная запись * (IE + РЕ) и, следовательно, IE[PE] именует тот же элемент массива, что и РЕ [IE] .

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

//Р5-13.СРР - коммутативность операции []
&include < iostream.h >
void main()
{ int =[] = { 10, 20, 30, 40 };
int j=1;
cout << "\nm [j] = << m [j];
cout << " * (m + j++) = " << * (m + j++);
cout << "\n*(++j + m) = " << *(++j + a);
cout << " j [m] = " << j [m];
cout << "\n*(j-- + m) = " << *(j-- + m);
cout << " j-- [m] = " << j-- [m];
cout << "\n* (--j + m) = " << * (--j + m);
cout << " --j [m] = " << --j [n];
cout << "\n3 [m] = "<< 3[m] << " 2[m]="<<2[m] <<
" 1[m] = " << 1 [m] <<" 0 [m] = " << 0[m];
}

Впечатляющий результат на экране:

m[j] =20    *(m + j++) = 20
*(++j + m) = 40    j [m] = 40
*(j-- + m) = 40    j-- [m] =30
*(--j + m) =10    --j[m] = 9
3[m] = 40     2[m] = 30     1[m] = 20     O [m] = 9

Обратите внимание на порядок вычислений. В выражении j--[m]: вычисляется j [m], а затем j--. В выражении --j [m]: вычисляется j [m], и результат уменьшается на 1, т.е. -- (j [m]).

В некоторых не совсем обычных конструкциях можно использовать постфиксное выражение PE[IE] с отрицательным значением индекса. В этом случае РЕ должен указывать не на начало массива, т.е. не на его нулевой элемент. Например, последовательность операторов:

char A [] = "СОН";
char *U = &А [2];
cout << "\n" << U[0] << 0[-1] << U[-2];

приведет к выводу на экран слова нос. Тот же результат будет получен при использовании оператора

cout << "\n" << *U << *U - - << *U- -;

То же самое слово будет выведено на экран при таком использовании вспомогательной переменной-индекса:

int i = 2;
cout << "\n" << i [A] << i [
А - 1] << i [A - 2];

Как видно из приведенных примеров, перемещение указателя от одного элемента к другому выполняется в естественном порядке, т.е. при увеличении индекса или указателя на 1 переходим к элементу с большим номером. Внутри массива нет проблемы "обратного" размещения в памяти последовательно определенных в программе объектов.

Так как имя массива есть не просто указатель, а указатель-константа, то значение имени массива невозможно изменить. Попытка получить доступ ко второму элементу массива int z[4] с помощью выражения * (++Z) будет ошибочной. А выражение * (z+l) вполне допустимо.

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

//Р5-14.СРР - адреса массивов и использование указателей
// для доступа
#include < iostream.h >
void main (void)
{int A[] = (1, 2, 3, 4, 5, 6);
int В[] = (1, 2, 3, 4, 5, 6);
int *рА = А, *рВ = &В[5];
cout << "\nАдреса массивов: &А = "<< &А <<
" &B = " << &В << "\n";
while (*рА < *рВ)
cout << " *рА++ + *рВ -- = " << *рА++ + *рВ--;
cout << "\n3начения указателей после цикла: ";
cout << "\n рА = " << рА << " рВ = " << рВ;
}

Результат выполнения программы:

Адреса массивов: &А = Ox8d8e0fec   &В = Ox8d8e0fe0
*рА++ + *рВ-- = 7   *рА++ + *рВ-- = 7   *рА++ + *рВ-- = 7
Значения указателей после цикла:
рА = Ox8d8e0ff2   рВ = Ox8d8e0fe4

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

int *рА = &А[0],   *рВ = (В + 5);

Как видно по значениям адресов &А, &B, массивы А И В размещены в памяти в обратном порядке по сравнению с их определением в программе. Внутри массивов элементы размещены в естественном порядке.

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

char stroka [] = {'S', 'I', 'С', '\0'};

При такой инициализации списком в конце символьного массива можно явно записать символ ' \0 '. Только при этом одномерный массив (в данном случае stroka) получает свойства строки, которую можно использовать, например, в библиотечных функциях для работы со строками или при выводе строки на экран дисплея с помощью оператора cout << stroka;

Продолжая изучать массивы и указатели, рассмотрим конструкцию:

type *имя;

В зависимости от контекста она описывает или определяет различные объекты типа type *. Если она размещена вне любой функции, то объект есть внешний указатель, инициированный по умолчанию нулевым значением.

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

type *имя = имя_ уже_ определенного_массива_типа_tуре;
type *имя = new type [размер_массива];
type *имя = (type *) mаllос (размер * sizeof (type));

Например:

long arlong [] = {100, 200, 300, 400}; // Определили
// массив
long *arlo = arlong; // Определили указатель,
// связали его с массивом
int *arint = new int[4]; // Определили указатель
// и выделили участок памяти
float *arfloat = new float[4]; // Определили указатель
// и выделили участок памяти
double *ardouble = // Определили указатель и
(double *) malloc (4 * sizeof (double)); // выделили участок
// памяти

В примерах определены четыре массива из 4-х элементов в каждом.

Массив arlong инициализирован списком начальных значений в фигурных скобках. Массив, связанный с указателем arfloat, с помощью операции new получил участок памяти нужных размеров (16 байт), однако эта память явно не инициализирована. Без инициализации остается и массив, связанный с указателем arint. Память для элементов массива, связанного с указателем ardouble, выделена с помощью библиотечной функции malloc() языка Си. В ее параметре приходится указывать количество выделяемой памяти (в байтах). Так как эта функция возвращает значение указателя типа void *, то потребовалось явное преобразование типа (double *).

Выделенная память явно не инициализирована.

В отличие от имени массива указатель, связанный с массивом, никогда не "вспоминает" об этом факте.

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

sizeof arint == 4 - длина указателя
int * sizeof *arint == 2 - длина элемента arint [О]
sizeof arlong ==16
sizeof arlo == 4

Как и при обычном определении массивов типа char, указатели char * могут инициализироваться с помощью символьных строк:

char *имя_указателя = "символьная строка";
char *имя_указателя = {<<символьная строка>>};
char *имя_указателя ("символьная строка");

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

char *car1 = "строка-1";
char *car2 = (<<строка-2>>};
char *саr3 ("строка-3");

Длины массивов, связанных с указателями car1, car2, саrЗ, одинаковы. В последнем элементе каждого из этих массивов находится символ ' \0 '.

Операция sizeof, примененная к указателю на символьный массив, возвращает длину не массива, а самого указателя, например, sizeof(carl) == 4.

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

//Р5-15.СРР - Копирование массивов-строк
#include < iostream.h >
#include                                   // Для функции strlen ()
void main ()
{ char *arch = "0123456789";    // Массив из 11 элементов
int k = strlen(arch) + 1;//   k - размер массива
char * newar = new char[k];
for (int i = 0; i < k;)
{ newar[i++] = *arch++;
if (!
(i%3)) cout << "\narch = " << arch;
}
cout << "\nk = " << k << " newar =" << newar;
cout << "\nsizeof (arch) = " << sizeof (arch);
}

Результат выполнения программы:

arch = 3456789
arch = 6789
arch = 9
k = 11   newar = 0123456789
sizeof (arch) = 4

Для определения длины массива, не имеющего фиксированного имени, нельзя использовать операцию sizeof. Поэтому в заголовке программы включен файл string. h с прототипами функций для работы со строками (см. табл. ПЗ.4 в приложении 3). Одна из них, а именно функция strlen (), определяющая длину строки-параметра, использована для определения количества элементов в массиве, связанном с указателем arch. Функция strlen() возвращает количество "значащих" символов в строке без учета конечного нулевого символа.

Именно поэтому при определении значения k к результату strlen(arch) прибавляется 1.

В программе определен и инициализирован символьный массив-строка, связанный с указателем arch, и выделена память операцией new для такого же по типу и размерам, но динамического и неинициализированного массива, связанного с указателем newar. Длина каждого из массивов с учетом "невидимого" в строке инициализации символа '\0' равна 11. "Перебор" элементов массивов в программе выполняется по-разному.

Доступ к компонентам массива, связанного с указателем newar, реализован с помощью операции [], к элементам второго массива - с помощью разыменования *. У массива, связанного с указателем newar, изменяется индекс. Указатель arch изменяется под действием операции ++. Такой возможности не существует для обычных массивов.

В программе использована еще одна возможность вывода с помощью операции << в стандартный поток cout - ему передается имя (указатель) массива, содержащего строку, а на экран выводятся значения всех элементов массива в естественном порядке, за исключением последнего символа '\0'. При этом необязательно, чтобы указатель адресовал начало массива. Указатель arch "перемещается" по элементам массива, поэтому в цикле выводятся в поток cout разные "отрезки" исходной строки. Чтобы сократить количество печати, в цикл добавлен условный оператор, в котором проверяется значение модуля i%3.

Обратите внимание, что здесь выполнен вывод массива-строки. Если бы указатель newar был связан не со строкой, а с массивом произвольного типа, то вывод содержимого на экран дисплея с помощью cout << был бы невозможен.

Итак, в случае определения массива с использованием указателя этот указатель является переменной и доступен изменениям. Такими свойствами обладают arch и newar в нашей программе. Вот еще варианты циклов копирования:

for (; *newar!='\0'; *newar++ = *arch++);
while (*newar++ = *arch++);

Результат будет тем же самым. Однако указатель newar в обоих случаях сместится с начала массива, и его нельзя в дальнейшем использовать, например, для печати строки.

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

int pi1 [ ] = { l, 2, 3, 4 };
int *pi2 = pi1;    // pi2 - "другое имя" для pi1
double pd1 [] = { 10, 20, 30, 40, 50 };
double *pd2 = pd1;    // pd2 - "другое имя" для pd1

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

cout << pi2[0];   // Выводится 1
*pil = О;          // Изменяется pi1[0]
cout << *pi2;    // Выводится О
cout << pd1[3];   // Выводится 40
*(pd2 + 3) = 77;   // Изменяется pd2[3]
cout << pd1[3]    // Выводится 77.

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

int *pi3;
pi3 = pil;

свяжет еще один указатель pi3 с тем же самым массивом int из четырех элементов.

Возможность доступа к элементам массива с помощью нескольких указателей не следует путать с продемонстрированной в программе Р5-15.СРР схемой присваивания одному массиву значений элементов другого массива. Рассмотрим такой пример:

char str[] = "массив";   // Определили массив с именем str
char *pstr = str;        // Определили указатель patr и
  // "настроили" его на массив str
pstr = "строка";        // Изменили значение указателя,
  // но никак не изменили массив str

Присваивание указателю pstr не переписывает символьную строку "строка" в массив str, вместо этого изменится значение самого указателя pstr. Если при определении он указывал на начало массива с именем str, то после присваивания его значением станет адрес того участка памяти, в котором размещена строковая константа "строка". Чтобы в процессе выполнения программы изменить значения элементов массива, необходимо, явно или опосредованно (с помощью указателей или средств ввода данных), выполнить присваивания. Например, заменит содержимое массива-строки str такой дополнительный оператор

while (str++ = pstr++);

или его аналог с индексированными переменными:

for (int i = 0; str[i] = pstr[i]; i++);

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

Примечание. Для копирования строк в стандартной библиотеке языков Си и Си++ имеется функция strcpy(), прототип которой находится в заголовочном файле string. h (см. табл. ПЗ.4).

Возможно "настроить" на массив указатели других типов, однако при этом потребуются явные приведения типов:

char *pch = (char *) pi1;
float *pfl = (float *) pi1;

Так, определенные указатели позволят по-другому "перебирать" элементы массива. Выражения * (pch + 2) или рсh[2] обеспечивают доступ к байту с младшим адресом элемента pil[l]. Индексированный элемент pfl[l] и выражение *(pfl + 1) соответствуют четырем байтам, входящим в элементы pil [2], pil [3]. Например, присваивание значения индексированному элементу pfl[l] изменит в общем случае как pil [2] = 3, так и pi1 [3] = 4. После выполнения операторов

pfl[l] = 1.0/3.0;
cout << "\npi1 [2] = " << pi1[2] << " pi1[3] = " <<
                                   pi1 [3];

на экране появится такой результат:

pil[2] = -21845    pil[3] = 16042

что совсем не похоже на исходные значения:

pi1[2] = 3    pi1[3] = 4

Итак, допустимо присваивать указателю адрес начала массива. Однако имя массива, являясь указателем, не обладает этим свойством, так как имя массива есть указатель-константа. Рассмотрим пример:

long arl[] = { 10, 20, 30, 40 };
long *p1 = new long[4];

Определены два массива по 16 байт каждый. Операторы присваивания для имен этих массивов обладают разными правами:

art = p1; // Недопустимый оператор
p1 = ar1; // Опасный оператор

Первый оператор недопустим, так как имя массива arl соответствует указателю-константе. Второй оператор синтаксически вереи, однако, приводит к опасным последствиям - участок памяти, выделенный операцией new long[4], становится недоступным. Его нельзя теперь не только использовать, но и освободить, так как в операции delete нужен адрес начала освобождаемой памяти, а его значение потеряно.

Мы неоднократно отмечали особую роль символьных строковых констант в языках Си и Си++. В языке Си++ нет специального типа данных "строка". Вместо этого каждая символьная строка в памяти ЭВМ представляется в виде одномерного массива типа char, последним элементом которого является символ '\0'. Изображение строковой константы (последовательность символов, заключенная в двойные кавычки) может использоваться по-разному. Если строка используется для инициализации массива типа char, например, так:

char array[] = "инициализирующая строка";

то адрес первого элемента строки становится значением указателя-константы (имени массива) array.

Если строка используется для инициализации указателя типа char *

char * pointer = "инициализирующая строка";

то адрес первого элемента строки становится значением указателя-переменной (pointer).

И, наконец, если использовать строку в выражении, где разрешено применять указатель, то используется адрес первого элемента строки:

char *string;
string = "строковый литерал" ;

В данном примере значением указателя string будет не вся строка "строковый литерал", а только адрес ее первого элемента.

5.4. Многомерные массивы, массивы указателей, динамические массивы

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

type имя_массива[К1][К2]...[KN];

Здесь type - допустимый тип (основной или производный), имя_массива - идентификатор, N - размерность массива, k1 - количество в массиве элементов размерности N-1 каждый и т.д. Например:

int ARRAY[4][3][6];

Трехмерный массив ARRAY состоит из четырех элементов, каждый из которых - двухмерный массив с размерами 3 на 6. В памяти массив ARRAY размещается в порядке возрастания самого правого индекса (рис. 5.3), т.е. самый младший адрес имеет элемент ARRAY [0] [0] [0], затем идет ARRAY [0] [0] [l] и т.д.

Рис. 5.3. Схема размещения в памяти трехмерного массива

Следующая программа иллюстрирует перечисленные особенности размещения в памяти многомерных массивов:

//Р5-16.СРР - адреса элементов многомерных массивов
#include < iostream.h > void main()
{ int ARRAY[4][3][6];
cout << "\n &ARRAY[0] = " << &ARRAY[0];
cout << "\n &ARRAY[1] = " << &ARRAY[1];
cout << "\n &ARRAY[2] = " << &ARRAY[2];
cout << "\n &ARRAY[3] = " << &ARRAY[3];
cout << "\n &ARRAY[2][2][2] = " << &ARRAY[2][2][2];
cout << "\n &ARRAY12][2]13) = " << &ARRAY[2][2][3];
cout << "\n\"
Расстояние\":\n (unsigned long)&ARRAY [1] "
" - (unsigned long) &ARRAY[0] = " <<
(unsigned long)&ARRAY[1] - (unsigned long)<<&ARRAY[0];
}

Результат выполнения программы:

&ARRAY[0] = Ox8d840f70
&ARRAY[1] = Ox8d840f94
&ARRAY[2] = Ox8d840fb8
&ARRAY[3] = Ox8d840fdc
&ARRAY[2][2][2] = Ox8d840fd4
&ARRAY[2][2][3] = Ox8d840fd6
"
Расстояние":
(unsigned long)&ARRAY[1]-(unsigned long)int&ARRAY[0] = 36

Обратите внимание на равную двум разность адресов элементов ARRAY [2] [2] [3] и ARRAY [2] [2] [2]. Массив целочисленный, и на 16-разрядной ПЭВМ "длина" одного элемента равна двум байтам. "Расстояние" в байтах от элемента ARRAY [1] до ARRAY [О] равно 36, что соответствует целочисленному массиву с размерами 3 на 6.

С учетом порядка расположения в памяти элементов многомерного массива нужно размещать начальные значения его элементов и в списке инициализации. (Поправка на правостороннее написание фраз и слов в европейских языках в отличие от направления возрастания адресов на рис. 5.3. совершенно естественна.)

int ARRAY [4][3][6] = { О, 1, 2, 3, 4, 5, 6, 7 };

В данном определении начальные значения получили только "первые" 8 элементов трехмерного массива, т.е.:

ARRAY[0][0][0]  == 0
ARRAY[0][0][1]  == 1
ARRAY[0][0][2]  == 2
ARRAY[0][0][3]  == 3
ARRAY[0][0][4]  == 4
ARRAY[0][0][5]  == 5
ARRAY[0][1][0]  == 6
ARRAY[0][1][1]  == 7

Остальные элементы массива ARRAY остались неинициализированными и получат начальные значения в соответствии со статусом массива.

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

Следующее определение с инициализацией трехмерного массива

int А[4][5][6] = { { {0} },
{ {100}, {110, 111}},
{{200}, {210}, {220, 221, 222}};

так задает только некоторые значения его элементов:

А[0][0][0] == 0,
А[1][0][0] == 100, А[1][1][0] == 110, A[1] [1] [1] == 111
А[2][0][0] == 200, А[2][1][0] = 210,
А[2][2][0] == 220, А[2][2][1] == 221, А[2] [2] [2] == 222

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

float matrix [] [5] = { {1},
{2},
{3]};

формирует массив matrix с размерами 3 на 5, но не определяет явно начальных значений всех его элементов. Оператор

cout << "\nsizeof(matrix) = " << sizeof (matrix);

выведет на экран:

sizeof (matrix) = 60

Начальные значения получают только

matrix[0][0] == 1
matrix[l][О] == 2
matrix[2][0] == 3

Как и в случае одномерных массивов, доступ к элементам многомерных массивов возможен с помощью индексированных переменных и с помощью указателей. Возможно объединение обоих способов в одном выражении. Чтобы не допускать ошибок при обращении к элементам многомерных массивов с помощью указателей, нужно помнить, что при добавлении целой величины к указателю его внутреннее значение изменяется на "длину" элемента соответствующего типа. Имя массива всегда константа-указатель. Для массива, определенного как type AR [N] [M] [L] , AR - указатель, поставленный в соответствие элементам типа type [M] [L] .

Добавление 1 к указателю AR приводит к изменению значения адреса на величину sizeof(type) * М * L .

Именно поэтому выражение * (AR + 1) есть адрес элемента AR[1], т.е. указатель на массив меньшей размерности, отстоящий от начала массива, т.е. от &AR[O], на размер одного элемента type[M] [L]. Сказанное иллюстрирует следующая программа:

//Р5-17.СРР - многомерные массивы - доступ по указателям
#include < iostream.h >
void main()
{int b[3][2][4] = {0, 1, 2, 3,
10, 11, 12, 13,
100, 101. 102, 103,
110, 111, 112, 113,
200, 201, 202, 203,
210, 211, 212 213
};
// Адрес массива b[] [] []
cout << "\nb = " << b;
// Адрес массива b[0] [] []:
cout << <<\n*b = " << *b;
// Адрес массива b[0][0][]:
cout << "\n**b = " << **b;
// Элемент b[0][0][0]:
cout << "\n***b = " << ***b;
// Адрес массива b[1] [] []:
cout << "\n* (b + 1) = " << *(b + 1);
"//Адрес массива b[2] [] []:
cout << "\n*(b + 2) = " << *(b + 2);
// Адрес массива b[0] [1] []:
cout << "\n*(*b + 1) = " << *(*b + 1);
cout << "\n* (*(*b+ 1) + 1) + 1) = " <<
*(*(*(b + 1) + 1) + 1);
cout << "\n*(b[1][1] + 1) = " << *(b [1] [1] + 1);
// Элемент b[2][0][0]:
cout << "\n*(b[1] +1)[1] = " << *(b[1] + 1) [1];
}

Результаты выполнения программы:

b = Ox8d880fd0
*b = Ox8d880fd0
**b = Ox8d880fd0 ***b = О
*(b + 1) = Ox8d880fe0
*(b + 2) = Ox8d880ff0
*(*b + 1) = Ox8d880fd8
*(*(*(b + 1) + 1) + 1) = 111
*(b[1][1] + 1) = 111
*(b[1] + 1) [1] = 200

В программе доступ к элементам многомерного массива осуществляется с помощью операций с указателями. В общем случае для трехмерного массива индексированный элемент b[i] [j] [k] соответствует выражению *(*(*(b + i) + j) + k). В нашем примере:

* (* (* (b + 1) + 1) +1) == b[l] [l] [l] == 111

Допустимо в одном выражении комбинировать обе формы доступа к элементам многомерного массива:

*b[1] [1] + 1) == 111

Как бы ни был указан путь доступа к элементу многомерного массива, внутренняя адресная арифметика, используемая компилятором, всегда предусматривает действия с конкретными числовыми значениями адресов. Компилятор всегда реализует доступ к элементам массива с помощью указателей и операции разыменования. Если в программе использована, например, такая индексированная переменная: AR[i] [j] [k], принадлежащая массиву type AR[N] [M] [L] , где N, M, L - целые положительные константы, то последовательность действий компилятора такова:

  •  выбирается адрес начала массива, т.е. целочисленное значение указателя AR, равное (unsigned long)AR;
  •  добавляется смещение i * (м * L) * sizeof(type) для вычисления начального адреса i-го массива с размерами м на L, входящего в исходный трехмерный массив;
  •  добавляется смещение для вычисления начального адреса j-й строки (одномерный массив), включающей L элементов. Теперь смещение равно (i * (м * L) + j * L) * sizeof(type);
  •  добавляется смещение для получения адреса k-го элемента в строке, т.е. получается адрес (unsigned long) (i * (м * L) + j * L + k) * sizeof(type);
  •  применяется разыменование, т.е. обеспечивается доступ к содержимому элемента по его адресу: * ((unsigned long) (i * (M * L) + j * L + k)).

Массивы указателей. Синтаксис языка Си++ в отношении указателей непротиворечив, но весьма далек от ясности. Для понимания, что же определено с помощью набора звездочек, скобок и имен типов, приходится аккуратно применять синтаксические правила, учитывающие последовательность выполнения операций. Например, следующее определение

int *array[6];

вводит массив указателей на объекты типа int. Имя массива array, он состоит из шести элементов, тип каждого int *. Определение

int (*ptr)[6];

вводит указатель ptr на массив из шести элементов, каждый из которых имеет тип int. Таким образом, выражение (array + l) соответствует перемещению в памяти на sizeof (int *) байтов от начала массива (т.е. на длину указателя типа int *). Если прибавить 1 к ptr, то адрес изменится на величину sizeof(int[6]), т.е. на 12 байт при двухбайтовом представлении данных типа int.

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

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

Если определять его как двухмерный массив элементов типа char, то в определении для элементов массива необходимо задать предельные размеры каждого из двух индексов.

Таким образом, "прямолинейное" определение массива для хранения списка фамилий может быть таким:

char spisok[25][20];

Для примера здесь предполагается, что количество фамилий в списке не более 25 и что длина каждой фамилии не превышает 19 символов (букв). После такого определения или с помощью инициализации в самом определении в элементы spisok[0], spisok[l], ... можно занести конкретные фамилии, представленные в виде строк. Размеры так определенного массива всегда фиксированы.

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

char spisok[][20] = { "Иванов",
                                                          "Петров", "Сидоров"};

Теперь в массиве spisok только 3 элемента, каждый из них длиной 20 элементов типа char (рис. 5.4).

Рис. 5.4. Двухмерный массив char spisok[3] [20] и одномерный массив указателей char *pointer [З], инициализированные одинаковыми строками

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

В противоположность этому при определении и инициализации теми же символьными строками одномерного массива указателей типа char * память распределяется гораздо рациональнее:

char *pointer [] = { "Иванов",
                                                     "Петров", "Сидоров"};

Для указателей массива pointer, в котором при таком определении 3 элемента и каждый является указателем-переменной типа char *, выделяется всего 3*sizeof (char *) байтов. Кроме того, компилятор размещает в памяти три строковые константы "Иванов" (7 байт), "Петров" (7 байт), "Сидоров" (8 байт), а их адреса становятся значениями элементов pointer[0], pointer[l], pointer[2]. Сказанное иллюстрирует рис. 5.4.

Применение указателей и их массивов позволяет весьма рационально решать задачи сортировки сложных объектов с неодинаковыми размерами. Например, для упорядочения (хотя бы по алфавиту) списка строк можно менять местами не сами строки, а переставлять значения элементов массива указателей на эти строки. Такой одномерный массив pointer[] использован в только что приведенном примере (см. рис. 5.4.). Накладными расходами при этой "косвенной" сортировке списков объектов является требование к памяти, необходимой для массива указателей. Выигрыш - существенное ускорение сортировки.

В качестве конкретной задачи такого рода рассмотрим сортировку строк матрицы. Матрица с элементами типа double представлена двухмерным массивом double array [n] [m], где n и m - целочисленные константы.

Предположим, что целью сортировки является упорядочение строк матрицы в порядке возрастания сумм их элементов. Чтобы не переставлять сами строки массива array [n] [m], введен вспомогательный одномерный массив указателей double * par[n]. Инициализируем его элементы адресами одномерных массивов типа double [m], составляющих двухмерный массив array [n] [m].

После такой инициализации массива указателей к элементам исходного массива появляются два пути доступа:

  •  прямой - с помощью индексации имени массива array [i] [j] и
  •  косвенный - с помощью указателей вспомогательного массива par [i] [j].

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

//Р5-18.СРР - перестановка указателей на одномерные массивы
#include < iostream.h > // Для ввода-вывода
void main()
{ const int n = 5; // Количество строк матрицы
const int m = 7; // Количество столбцов матрицы
double array[n] [m]; // Основной массив (матрица)
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++) // Заполнение матрицы
array[i] [j] = n - i;
double *par[n]; // Вспомогательный массив указателей
for (i = 0; i < n; i++) // Цикл перебора строк
par[i] = (double *)&array[i];
// Печать массива по строкам (через массив указателей):
cout << "\nДо перестановки элементов массива " <<
"указателей:";
for (i = 0; i < n; i++) // Цикл перебора строк
{ cout << "\nстрока " << (i+1) << " : ";
for (int j = 0; j < m; j++) // Цикл печати
cout <<"\t"<< par[i] [j]; // элементов строки
}
// Упорядочение указателей на строки массива
double si,sk;
for (i = 0; i < n - 1; i++)
{ for (int j = 0, si = 0.0; j < m; j++)
si += par[i][j]; // Сумма элементов i-й строки
for (int k = i + 1; k < n; k++)
{ for (j =0, sk = 0.0; j < m; j++)
sk += par[k][j]; // Сумма элементов k-й строки
it (si > sk)
{ double *pa = par[i];
par[i] = par[k]; par[k] = pa;
double a = si; si = sk; sk = a;
}
}
}
// Печать массива no строкам (через массив указателей):
cout << "\nПосле перестановки элементов массива "
"указателей:";
for (i = 0; i < n; i++) // Цикл перебора строк
{cout << "\n строка " << (i + 1) << " : ";
for (int j=0; j < m; j++) // Цикл печати
cout << "\t" << par[i] [j]; // элементов строки
}
// Печать исходного массива по строкам (обращение через
// имя массива):
cout << "\nИcходный массив остался без изменений:";
for (i = 0 ; i < n; i++) // цикл перебора строк
{ cout << "\n строка " << (i + 1) << " :";
for (int j = 0; j < m; j++) // Цикл печати
cout << "\t" << array[i] [j]; // элементов
// строки
}
}

Результаты выполнения программы:

До перестановки элементов массива указателей:
строка 1: 5 5 5 5 5 5 5
строка 3: 3 3 3 3 3 3 3
строка 4: 2 2 2 2 2 2 2
строка 5: 1 1 1 1 1 1 1
После перестанoвки элементов массива указателей:
строка 1: 1 1 1 1 1 1 1
строка 2: 2 2 2 2 2 2 2
строка 3: 3 3 3 3 3 3 3
строка 5: 5 5 5 5 5 5 5
Исходный массив остался без изменений:
строка 1: 5 5 5 5 5 5 5
строка 3: 3 3 3 3 3 3 3
строка 4: 2 2 2 2 2 2 2
строка 5: 1 1 1 1 1 1 1

Обратите внимание на неизменность исходного массива array [n] [и] после сортировки элементов вспомогательного массива указателей. Для иллюстрации действия механизма сортировки нарисуйте схему взаимосвязи массивов array [] [] и par[]. В качестве образца можно воспользоваться рис. 5.5.

Массивы динамической памяти. В соответствии с синтаксисом операция new при использовании с массивами имеет следующий формат:

new тип_ массива

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

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

long (*1р)[2][4]; // Определили указатель
lр = new long[3][2][4]; // Выделили память для массива

В данном примере использован указатель на объекты в виде двухмерных массивов, каждый из которых имеет фиксированные размеры 2 на 4 и содержит элементы типа long. В определении указателя следует обратить внимание на круглые скобки, без которых обойтись нельзя. После выполнения приведенных операторов указатель 1р становится средством доступа к участку динамической памяти с размерами 3 * 2 * 4 * sizeof(long) байтов. В отличие от имени массива (имени у этого массива из примера нет) указатель 1р есть переменная, что позволяет изменять его значение и тем самым, например, перемещаться по элементам массива.

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

delete [] lр;

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

//Р5-19.СРР - выделение и освобождение памяти для массива
#include < iostream.h >
void main ()
{long (*lp)[2] [4];
lap = new long [3] [2] [4];
cout << "\n";
for (into i = 0; i < 3; i++)
{ cout << "\n";
for (into j = 0; j < 2; j++)
for (int k = 0; k < 4; k++)
{ lp[i] [j] [k] = i + j + k;
cout << '\t' << lp[i] [j] [k];
}
}
delete [] lр;
}

Результаты выполнения программы:

01231234
12342345
23453456

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

new long[] // Ошибка, размер неизвестен
new long[] [2] [4] // Ошибка, размер неизвестен
new long[3] [] [4] // Ошибка, размер неизвестен

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

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

//Р5-20.СРР - единичная диагональная матрица с изменяемым
// порядком
#include < iostream.h > // Для ввода-вывода
void main()
{ int n; // Порядок матрицы
cout << "\nВведите порядок матрицы:";
cin >> n; // Определяются размеры массива
float **matr; // Указатель для массива указателей
matr = new float *[n]; // Массив указателей
float * if (matr == NULI.)
{ cout << "He создан динамический массив!";
return; // Завершение программы
}
for (int i = 0; i < n; i++)
{ // Строка-массив значений типа float:
matr[ i ] = new float[n];
if (matr[i] == NULL)
{ cout << "He создан динамический массив!";
return; // Завершение программы
}
for (int j = 0; j < n; j++) // Заполнение матрицы
// Формирование нулевых элементов:
if (i != j) matr[i] [j] = 0;
else
// Формирование единичной диагонали:
matr[i] [j] = 1;
}
for (i = 0; i < n; i++) // Цикл перебора строк
{ cout << "\n строка " << (i + 1) << " : ";
// Цикл печати элементов строки:
for (int j = 0; j < n; j++)
cout << "\t" << matr[i][j];
}
for (i = 0; i < n; i++)
delete matr[i];
delete[] matr;
}

Результаты выполнения:

Введите размер матрицы: 5 < Enter >
Строка 1: 1 0 0 0 0
Строка 2: 0 1 0 0 0
Строка З: 0 0 1 0 0
Строка 4: 0 0 0 1 0
Строка 5: 0 0 0 0 1

На рис. 5.5 изображена схема взаимосвязи (n + 1)-одномерных массивов, из п элементов каждый. Эти (n + 1) массивов совместно имитируют квадратную матрицу с изменяемыми размерами, формируемую в программе Р5-19 .СРР.

Рис. 5.5. Схема имитации двухмерного динамического массива с помощью массива указателей и набора одномерных массивов

5.5. Организация памяти в процессорах 80х86 и указатели языка Си++

Сегментная адресация памяти. В стандартизируемом варианте языка Си++ предполагается [2], что все указатели одинаковы, т.е. внутреннее представление адресов всегда одно и то же. Однако в реализациях компиляторов для конкретных вычислительных машин это не всегда справедливо. Практически все компиляторы языка Си++ обязательно учитывают архитектурные особенности аппаратных средств и включают дополнительные возможности для того, чтобы программист мог их эффективно использовать. Рассмотрим этот вопрос подробнее, ориентируясь на ПЭВМ типа IBM PC. Читатели, работающие с компиляторами для других платформ, могут безболезненно пропустить этот раздел.

Процессоры семейства 80х86 (80286, 80386, 80486) используют сегментированную организацию памяти, и это существенно влияет на внутреннее представление адресов.

Основная память ПЭВМ - это память с произвольной выборкой, т.е. с непосредственным (прямым) доступом к участку с любым адресом независимо от того, к какому участку выполнялось предыдущее обращение.

Наименьшим адресуемым участком основной памяти является байт, содержащий 8 бит (двоичных разрядов). 256 возможных значений байта (28 = 256) могут рассматриваться либо как положительные числа в диапазоне от 0 до 255 (unsigned), либо как целые числа со знаком в диапазоне от -128 до +127. В последнем случае старший разряд считается знаковым, а остальные 7 бит представляют абсолютное значение хранимого числа. Физические адреса байтов памяти начинаются с 0 и возрастают.

Полный сегментированный адрес любого типа формируется из двух 16-разрядных чисел, которые можно условно записать в виде двух шестнадцатеричных чисел вида ОхHHHH: ОхHHHH, где H - любая шестнадцатеричная цифра (от 0 до F). Первое из них (старшее) называют сегментной частью адреса, второе (младшее) именуют смещением или относительной частью адреса. Рассмотрим эти понятия подробнее.

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

Участки памяти длиной 16 байт, начинающиеся с адресов, кратных 16, называют параграфами. Параграфы пронумерованы последовательно и в памяти с объемом 1 Мбайт имеют нумерацию от 0 до 65535. Физический адрес каждого параграфа, т.е. адрес байта, с которого он начинается в памяти, равен его номеру, умноженному на 16.

Начало любого параграфа может быть принято за начало сегмента длиной не более 4096 параграфов. Таким образом, адрес начала произвольного сегмента всегда кратен 16, а длина сегмента не может превышать 64Кбайт (65536 байт). При задании сегмента необходимо указать только его начало. Размеры сегмента нигде не указываются и ограничены разрядностью относительной части адреса.

В полном сегментированном адресе ОхHHHH: ОхHHHH старшее шестнадцатиразрядное число - сегментная часть адреса - это номер параграфа, с которого начинается сегмент. Номер параграфа однозначно определяет размещение сегмента в памяти. Младшее шестнадцатеричное число - относительная часть адреса - определяет смещение адресуемого байта от начала сегмента. Обе части полного адреса - это четырехразрядные шестнадцатеричные числа, т.е. они, могут принимать значения от 0 до 65535 (64К).

Любой байт пространства памяти может быть отнесен к нескольким сегментам, имеющим разные начальные адреса и (или) разные длины. Физический адрес байта, отнесенного к конкретному сегменту, формируется как сумма относительной части его адреса и увеличенной в 16 раз сегментной части адреса. Например, по известному полному адресу 0х2222:0х3333 будет сформирован 20-разрядный физический адрес 0х25553.

Для организации работы с полными сегментированными адресами в процессорах семейства 80х86 имеются регистры сегментов:

  •  CS (Code Segment) - регистр кодового (программного) сегмента используется для формирования адресов выполняемых команд программы;
  •  DS (Data Segment) - регистр сегмента данных используется для формирования адресов данных, участвующих в операциях процессора;
  •  SS (Stack Segment) - регистр сегмента стека используется для вычисления адресов данных из стека;
  •  ES (Extra Segment) - регистр сегмента расширения используется для формирования адресов дополнительных данных.

Каждый сегментный регистр может содержать при выполнении программы конкретные значения сегментной части адреса. Таким образом, процессор одновременно может адресовать 4 различных сегмента памяти, каждый из которых может быть размером до 64К. Эти сегменты могут пересекаться и даже могут быть размещены все в одном участке памяти размером 64К (рис. 5.6).

Рассматривая механизм сегментации, обратите внимание на неполное использование потенциально возможного адресного пространства, образуемого двумя четырехразрядными шестнадцатеричными числами (сегмент:смещение). Действительно, с помощью этих чисел можно было бы адресовать 216х 216 = 232 (т.е. ~ 4 млрд.) байт памяти. Однако адресуются только 2M(~ 1 млн.) байт. Причина такого положения - допустимость пересечения сегментов. Напомним, что началом любого сегмента может быть любой байт, адрес которого кратен 16, т.е. оканчивается нулем в шестнадцатеричном представлении порядкового номера байта. Таким образом, следующие полные адреса 0х0000: 0х0413, Ох0001:0х0403, 0х0021:0х0203 представляют один и тот же физический адрес 0х0413 (0х00000 + 0х0413 == 0х00010 + 0х0403 == 0х00210 + 0х0203 == 0х413), определяющий слово в памяти, где хранится информация о размерах памяти ПЭВМ типа IBM PC (размеры в Кбайтах).

Сегментная адресация и указатели в компиляторах ТС++ и ВС++.

С сегментной организацией памяти и вопросами адресации при ее использовании тесно связаны вопросы внутреннего представления значений указателей. В конкретной реализации компиляторов языка

Рис. 5.6. Возможное размещение сегментов в памяти

Си++ стандартное понятие указателя расширено. Введены дополнительные модификаторы - ключевые (служебные) слова, позволяющие программисту выбирать внутреннее представление указателей. Указатели ТС++ и ВС++ делятся на 4 группы:

  •  near- "ближние";
  •  far - "дальние";
  •  huge- "нормализованные" ("сверхдальние" или "огромные");
  •  сегментные (_seg).

Модификатор размещается при определении указателя непосредственно перед символом ' * ' и относится (как символ ' * ' и как инициализатор) только к одному указателю в определении.

Ближние указатели (пеаr-указатели) имеют длину внутреннего представления 2 байта (16 бит). Они позволяют определять адреса объектов в конкретном сегменте, так как каждый nеаr-указатель хранит только смещение полного адреса. nеаг-указатели можно использовать только в тех случаях, когда начальный адрес сегмента не требуется указывать явно. С помощью пеаr-указателей организуется доступ к функциям или данным внутри одного сегмента. Определение ближнего указателя:

тип near *имя_указателя инициализатор;

Ближние указатели достаточно удобны, т.к. при операциях с ближними указателями нет необходимости следить за начальным адресом сегмента. Однако пеаr-указатели не позволяют адресовать более 64К памяти.

Дальние указатели (far-указатели) занимают 4 байта (32 бита) и содержат как сегментную часть полного адреса, так и смещение. С помощью far-указателей можно адресовать память до 1 Мбайта. Определение дальнего указателя:

тип far *имя указателя инициализатор;

Для формирования значения far-указателя в компиляторах языка Си++ имеется унаследованный от языка Турбо Си макрос, определение которого находится в заголовочном файле dos.h. Макрос имеет вид:

void far *MK_FP (unsigned segment, unsigned offset);

С его помощью формируется дальний указатель по заданным целочисленным неотрицательным значениям сегментной части адреса (segment) и смещению (offset).

Как уже говорилось, представление одного и того же физического адреса с помощью полного сегментированного адреса неоднозначно. Один и гот же байт памяти можно относить к разным сегментам и тем самым адресовать разными парами сегмент:смещение. Например, как показано в следующей программе, один и тот же основной байт состояния клавиатуры с физическим адресом 0х417 можно адресовать несколькими указателями:

//Р5-21.СРР - Представление дальних (far) указателей
#include < iostream.h >
#include // Для макроса NK_FP()
void main(void)
{ void far *p0 = (void far *)0x417;
void far *pl;
pi = MK_FP(0х41,0х007);
void far *p2;
p2 = MK_FP(0х20,0х217);
cout << "\np0 = " << p0 <<
" *(char *)p0 = " << *(char *)p0;
cout << "\npl = " << p1 <<
" *(char *)p1 = " << *(char *)p1;
cout << "\np2 = " << p2 <<
" *(char *)p2 = " << *(char *)p2;
}

Результат выполнения программы:

р0 = 0х00000417    *(char *)p0 = А
p1 = 0х00410007    *(char *)p1 = А
р0 = 0х00200217    *(char *)p2 = А

Указатель р0 определен с инициализацией. Указателям p1, p2 начальные значения присвоены с помощью макроопределения MK_FP. Интересен внешний вид выводимых шестнадцатеричных значений указателей. Они выводятся как восьмиразрядные числа. Первые четыре разряда - сегментная часть полного адреса, младшие четыре разряда - смещение. Все три указателя "смотрят" на один и тот же байт состояния клавиатуры. Как мы видели в программе Р5-02.СРР (см. п. 5.1), содержимое байта с номером 0х417 при нормальном состоянии клавиатуры именно такое. Если выполнить программу, включив регистры Caps Lock, Num Lock и Scroll Lock, то результатом разыменования будет 'E'. Обратите внимание на необходимость явных приведений типов как при инициализации р0, так и при выводе в стандартный поток cout результатов разыменования указателей р0, р1,р2.

Итак, значением far-указателя р0 будет полный сегментный адрес 0х0:0х417, а значением p1 будет 0х41:0х7. При выполнении сравнения указателей р0 и р1 значением выражения р0 == p1 будет нуль (ложь), несмотря на то, что оба указателя адресуют один и тот же участок памяти с порядковым номером 0х417. Сравнение far-указателей выполняется "поэлементно", т.е. попарно сравниваются адреса сегментов и адреса смещений, а не абсолютные физические адреса, которые им соответствуют. Точнее, при сравнении far-указателей на равенство каждый из них представляется в виде длинного целого без знака (unsigned long), и попарно сравниваются все 32 бита их внутренних представлений.

При сравнении дальних указателей на "старшинство" (т.е. в отношениях >, >=, <=, <) в сравнениях участвуют только смещения. Таким образом, отношение р0 >= p1 для far-указателей из программы будет истинным (равным 1), а сравнение р1 >= p2 даст значение о (ложь).

При увеличении (или уменьшении) far-указателя за счет прибавления (или вычитания) константы изменяется только смещение. Это правило справедливо и при выходе смещения за разрядную сетку, в случае которого указатель циклически переходит к "противоположной" границе того сегмента, на который указывает сегментная часть его значения.

При изменении far-указателя на значение константы соблюдаются обычные правила арифметических действий с указателями. Для указателя типа type прибавление (или вычитание) целой величины c изменяет внутреннее значение смещения на величину с * size (type). Перечисленные особенности far-указателей иллюстрирует следующая программа:

//Р5-22.СРР - сравнения и аддитивные операции с
// far-указателями
#include < iostream.h >
#include < dos.h > // Для макроса MK_FP()
void main(void)
{ int far *u1, far *u2;
u1 = (int far *)MK_FP(Ox8000,OxFFFO);
u2 = ul + 0х20;
cout << "\nu1 = " << ul;
cout << "\nu2 = " << u2;
cout << "\nu2 - 0х20=" << u2 - 0х20;
u2 = (int far *)MK_FP (0х6000, OxFFFF);
cout << "\nu2 = " << u2;
cout << "\n(u1 >= u2) = " << (u1 >= u2);
}

Результаты выполнения программы:

u1 = Ox8000fff0
u2 = 0х80000030
u2 - 0х20 = Ox8000fff0
u2 = Ox6000ffff
(ul >= u2) = 0

В программе нужно обратить внимание на особенность аддитивных операций с указателями. Напомним, что изменение указателя типа type * на 1 увеличивает либо уменьшает его числовое значение на величину sizeof (type). Именно поэтому, прибавив 0х20 к ul, получили смещение 0х0030, так как смещение ul изменилось на 0х40, т.е. на 0х20 * sizeof(int).

Обобщая полученные результаты, отметим, что, не изменив явно значения сегментной части в far-указателе, невозможно адресовать участки памяти вне сегмента размером 64К.

Кроме макроопределения MK_FP(), позволяющего сформировать far-указатель по известным значениям сегмента и смещения, в файле dos .h находятся еще два макроопределения для работы с удаленными указателями:

unsigned FP_OFF(void far *ptr);
unsigned FP_SEG (void far *ptr);

Первое - FP_OFF - возвращает беззнаковое целое, представляющее значение смещения из значения far-указателя ptr, использованного в качестве параметра.

Второе - FP_SEG - возвращает беззнаковое целое, представляющее значение адреса сегмента из значения far-указателя ptr, использованного в качестве параметра.

В описании библиотеки ТС++ имеется несколько примеров, иллюстрирующих особенности применения макроопределений. Следующая программа построена на их основе:

//Р5-23.СРР - сегментная и относительная части
// far-указателей
#include < iostream.h >
#include < dos.h >
void main(void)
{ char far *str = "Строка автоматической памяти";
cout << "\nАдрес строки: " << (void *)str;
cout << "\nСегментная часть адреса строки:";
cout << hex << FP_SEG(str);
cout << "\nСмещение для адреса этой строки:";
cout << hex << FP_OFF(str);
}

Результат выполнения программы:

Адрес строки: Ox8d200094
Сегментная часть адреса строки: 8d20
Смещение для этой строки равно: 94

Нормализованные указатели (huge-указатели) определяются таким образом:

тип huge *имя_ указателя инициализатор;

Инициализатор, как всегда, необязателен. Нормализованные указатели имеют длину 32 разряда и позволяют однозначно адресовать память до 1 Мбайта. В отличие от far-указателей huge-указатели содержат только нормализованные адреса, т.е. адрес любого объекта в памяти представляется единственным сочетанием значений его сегментной части и смещения. Однозначность представления полного сегментированного адреса выполняется за счет того, что смещение может принимать значение только в пределах от 0 до 15 (шестнадцатеричное F). Например, полный адрес 0х0000: 0х0417 будет нормализован так: 0х0041:0х0007. В качестве адреса сегмента всегда выбирается максимальное для конкретного адреса значение. Например, адрес 0х8000 :OxFFFF в нормализованном виде будет всегда представлен однозначно: Ox8PPF:OxOOOF. Именно такое значение содержит соответствующий huge-указатель.

Второе существенное отличие huge-указателя от far-указателя состоит в том, что значение huge-указателя (некоторый адрес) рассматривается всегда как одно беззнаковое целое число (32 разряда).

Тем самым при изменении значения нормализованного указателя может изменяться как смещение, так и сегментная часть. Если смещение превышает ОхF или становится меньше 0х0, то изменяется сегментная часть адреса. Например, увеличение на 2 huge-указателя со значением Ox8FFF:Oxp приведет к формированию 0х9000:0х1. Уменьшение последнего значения на 3 приведет к значению Ox8FFF:OxE и т.д.

При выполнении всех операций сравнения нормализованных (huge-) указателей используются все 32 разряда их внутренних представлений.

Так как каждый адрес представляется в виде huge-указателя единственным образом, то сравнение на равенство (== или!=) выполняется вполне корректно. Сравнение на старшинство (>, >=, <, <=) также приводит к правильному результату.

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

В качестве примера использования нормализованных указателей приведем программу вычисления суммы всех слов области адресов BIOS, начиная с адреса ОхFOOO:ОхОООО. Получаемая сумма уникальна для каждого варианта микросхемы BIOS и может использоваться, например, для настройки программы на конкретную ПЭВМ. Программа с некоторыми простейшими исправлениями и изменениями взята из работы [17]. Обратите внимание, что указатель ptr становится равным 0 после прибавления к его значению OXFFFFE величины 2, т.е. значения sizeof(unsigned):

//Р5-24.СРР - Нормализованные указатели - обращение к
// памяти
#include < iostream.h >
void main(void)
{unsigned huge *ptr = (unsigned huge *)OxFOOOOOOOL;
long unsigned bios_sum =0;
// Цикл пока указатель отличен от нуля:
while (ptr) bios_sum += *ptr++;
cout << "\nCyммa кодов BIOS: " << bios_sum;
}

Результат выполнения программы:

Сумма кодов BIOS: 837681152

Особый тип близких указателей в ВС++ и ТС++ - это сегментные указатели. Для определения и описания сегментных указателей в качестве модификаторов используются служебные слова _cs, _ds, _es, _ss, _seg, которые отсутствуют в стандарте языка Си++. В полном соответствии с обозначениями модификаторы _cs, _ds, _es, _ss позволяют определить четыре вида близких указателей, каждый из которых соответствует сегментному регистру, см. рис. 5.6. Определим для примера сегментный указатель pss:

int _ss *pss;

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

Сегментные указатели могут обоснованно использоваться в том случае, если надлежащим образом определены значения сегментных регистров. Для доступа к сегментным и другим регистрам в ТС++ и ВС++ введены в виде служебных слов регистровые переменные _CS, _DS, _ES, _SS (см. п. 2.2). К сожалению, для более подробного обсуждения особенностей и достоинств сегментных указателей требуется рассматривать задачи системного программирования, что выходит за рамки настоящей работы. Читателей можно отослать к справочному пособию А.И. Касаткина.

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

тип данных seg *имя_указателя;

Например:

long _seg *ptrseg;

Руководство программиста из документации ВС++ таким образом перечисляет свойства этих сегментных указателей [9].

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

1. К сегментным указателям разрешено применять операции ++, --,+=,-=.

2.Нельзя вычитать один сегментный указатель из другого.

3.В результате добавления ближнего (near-) указателя к сегментному указателю формируется дальний (far-) указатель, значением которого служит полный адрес, сегментная часть которого выбирается из сегментного указателя, а смещением является значение ближнего указателя. В связи с этим оба указателя (сегментный и ближний) должны быть одного типа либо один из них должен быть указателем на тип void. Независимо от типа указателей никаких изменений значения смещения не предусматривается.

4.Когда к сегментному указателю применяется разыменование, он предварительно автоматически преобразовывается к дальнему (far-) указателю, имеющему нулевое смещение.

5.При суммировании сегментного указателя с целочисленным операндом формируется удаленный указатель. Сегментной частью значения этого far-указателя служит значение сегментного указателя, а смещение вычисляется как значение целочисленного операнда, умноженного на длину объекта того типа, к которому относится сегментный указатель. При вычитании целого операнда из сегментного указателя формируется far-указатель. Сегментной частью значения этого far-указателя служит значение сегментного указателя, а смещение определяется вычитанием из (OXFFFF + 1) значения операнда, умноженного на размер объекта того типа, к которому относится исходный сегментный указатель. Арифметические операции с сегментными указателями выполняются так же, как сложение и вычитание для far-указателей. Другими словами, прибавляя (или вычитая) какой угодно целый операнд к сегментному указателю, мы всегда получаем far-указатель с сегментом, равным значению сегментного указателя. Изменяться может только смещение.

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

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

//Р5-25.СРР - Некоторые особенности сегментных указателей
#include < iostream.h >
void main(void)
{int near *pn = (int near *)0х0007;
int _seg *ps = (int _seg *)0х0041;
int far *pf;
pf = ps + pn;
cout << "\npf = " << pf;
cout << " *pf = " << *(char *)pf;
}

Результат выполнения программы:

pf = 0х00410007      *pf = A

При изменении состояния клавиатуры значение *pf изменяется. Это мы уже несколько раз рассматривали выше.

Глава 6. ФУНКЦИИ, УКАЗАТЕЛИ, ССЫЛКИ

6.1. Определения, описания и вызовы функций

Если в таких языках, как Алгол, Фортран, ПЛ/1, Паскаль и др. делается различие между программами, подпрограммами, процедурами, функциями, то в языке Си++ и в его предшественнике - в языке Си -используются только функции.

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

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

Совокупность формальных параметров определяет сигнатуру функции. Этот термин активно используется в связи с перегрузкой функций (см. п. 6.8). Сигнатура функции зависит от количества параметров, от их типов и от порядка их размещения в спецификации формальных параметров.

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

тип_ функции имя_функции(спецификация_формальных_параметров)
тело_функции

Здесь тип_функции - тип возвращаемого функцией значения, в том числе void, если функция никакого значения не возвращает. Имя_функции - идентификатор. Имена функций как имена внешние (тип extern) должны быть уникальными среди других имен из модулей, в которых используются функции. Спецификация формальных параметров - это либо пусто, либо void, либо список спецификаций отдельных параметров, в конце которого может быть поставлено многоточие. Спецификация каждого параметра в определении функции имеет вид:

тип имя_параметра тип имя_параметра = умалчиваемое_значение

Как следует из формата, для параметра может быть задано (а может отсутствовать) умалчиваемое значение. И, более того, синтаксис языка разрешает параметры без имен, если последние не используются в теле функции. В проекте стандарта языка Си++ [2] отмечается, что использование спецификации параметра без имени "полезно для резервирования места в списке параметров". В дальнейшем этот параметр может быть введен в функции без изменения интерфейса, т.е. без изменения вызывающей программы. Такая возможность бывает удобной при развитии уже существующей программы за счет изменения входящих в нее функций. (См. в качестве примера определенную ниже функцию Norma ().)

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

return выражение;

или

return;

Выражение в операторе return определяет возвращаемое функцией значение. Именно это значение будет результатом обращения к функции. Тип возвращаемого значения определяется типом функции. Если функция не возвращает никакого значения, т.е. имеет тип void, то выражение в операторе return опускается. В этом случае необязателен и сам оператор return в теле функции. Необходимые коды команд возврата в точку вызова компилятор языка Си++ добавит в объектный модуль функции автоматически. В теле функции может быть и несколько операторов return. Оператор return можно использовать и в функции main. Если тип возвращаемого функцией main значения отличен от void, то это значение анализируется операционной системой. Принято, что при благоприятном завершении программы возвращается значение 0. В противном случае возвращаемое значение отлично от 0.

Даже в том случае, когда функция не должна выполнять никаких действий и не должна возвращать никаких значений, тело функции будет состоять из фигурных скобок {}. (Такая функция может потребоваться при отладке программы в качестве "заглушки".)

Примеры определений функций с разными сигнатурами:

void print (char *name, int value) // Ничего не возвращает
{ cout << "\n" << name << value; // Нет оператора return
}
float min (float a, float b) // В функции два оператора
// возврата
{if (a < b) return а; // Возвращает минимальное
return b; // из значений аргументов
}
float cube (float x) // Возвращает значение типа float
{ return х * х * х; // Возведение в куб вещественного числа
}
int mах (int n, int m) // Вернет значение типа int
{return n < m? m: n; // Возвращает максимальное
// из значений аргументов
}
void write (void) // Ничего не возвращает,
{
// ничего не получает
cout << "\n НАЗВАНИЕ:"; // Всегда печатает одно и то же
}

Заголовок последней функции может быть записан без типа void в списке формальных параметров:

void write () // Отсутствие параметров эквивалентно void

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

double Norma(double X1, double Y1,
double X2, double Y2, double)
{ return X2 - X1 > Y2 - Y1 ?
X2 - X1 : Y2 - Y1; }

Последний параметр, специфицированный типом double, в теле функции Norma() не используется. В дальнейшем без изменения интерфейса можно изменить функцию Norma(), добавив в нее еще один параметр. Например, функция сможет вычислять расстояние между точками (x1, Y1), (x2, Y2) на плоскости, используя более сложную метрику, в которую будет входить показатель степени, передаваемый как значение последнего из параметров функции Norma (). Например, так можно ввести Евклидову метрику.

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

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

тип_функции имя_функции
(спецификация формальных параметров);

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

Приведем прототипы определенных выше функций:

void print(char *, int); // Опустили имена параметров
float min(float a, float b);
float cube(float x);
int mах(int, int m); // Опустили одно имя
void write(void); // Список параметров может быть пустым
double Norma(double, double, double, double, double);

Обратите внимание на прототип функции Nогmа(), в котором опущены имена всех параметров. Компилятор, "глядя" на прототип функции, никогда не догадается" (да и не станет об этом гадать), есть ли в определении функции конкретные имена формальных параметров. Именно поэтому не будет предупреждающих сообщений при обращении к функции Norma () с пятью фактическими параметрами вместо первых четырех, которые реально используются в ее теле.

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

имя_ функции (список_ фактических_ параметров)

Значением выражения "вызов функции" является возвращаемое функцией значение, тип которого соответствует типу функции.

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

Проиллюстрируем сказанное о функциях программой, в которую включим некоторые из приведенных выше функций. Предположим, что программа создается в виде одного модуля (т.е. в одном файле с расширением .СРР):

//Р6-01.СРР - определения, прототипы и вызовы функций
#include < iostream.h >
int max(int n, int m) // Определение до вызова функции
{ return n < m? m: n;) // Точка с запятой не нужна
void main(void) // Главная функция
{ void print(char *, int); // Прототип до определения
float cube(float x = 0); // Прототип до определения
int sum =5, k = 2;
// Вложенные вызовы функций:
sum = max((int)cube(float(k)), sum);
print("\nsum = ",sum);
}
void print(char * name, int value) // Определение функции
{cout << "\n" << name << value; }
float cube(float x) // Определение функции
{ return x * x * x; }

Результат выполнения программы:

sum = 8

Отметим необходимость преобразований типов фактических параметров при вызовах функции mаx () и cube(). Преобразования требуются для согласования типов формальных и фактических параметров. В иллюстративных целях формы записи преобразований взяты различными - каноническая и функциональная.

Для функции mах() прототип не потребовался - ее определение размещено в том же файле до вызова функции. Прототипы функций print () и cube () в программе необходимы, так как определения функций размещены после обращения к ним.

Если в качестве эксперимента убрать (например, превратить в комментарий с помощью скобок /* */ или //) прототип любой из функций print () или cube (), то компилятор выдаст сообщение об ошибке. ВС++ делает это так:

Error P6-01.CPP 10: function 'cube' should have a prototype

Такое же сообщение появится, если в нашей программе перенести определение функции mах() в конец модуля и не ввести прототипа в main.

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

#include <имя_файла>

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

#include < iostream.h >

которая из файла с именем iostream.h включает в программу описания библиотечных классов и принадлежащих им функций для ввода и вывода данных. (Из всех средств, описанных в файле iostream.h, мы до сих пор использовали только объекты потокового ввода-вывода coat, cin и соответствующие им операции >>, <<.) Попробуйте удалить из любой работающей программы директиву

#include < iostream.h >

(если, конечно, она в ней есть) и посмотрите на возмущенные сообщения компилятора - он перестанет "узнавать" многие конструкции в тексте программы. Например, в нашей программе станет неизвестным символ cout.

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

#include "имя_файла"

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

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

//EXAMPLE. HРР - прототипы функций из примеров:
void print(char * " "
номер страницы", int k = 1);
float min(float a, float b);
float cube(float x = 1);
int max(int, int m = 0);
void write (void);
double Norma(double, double, double, double, double);

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

void print(char* name = "Номер дома: ", int value = 1)
{ cout << "\n" << name << value; }

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

print(); // Выводит: 'Номер дома: 1'
print("Номер комнаты: "); // Выводит: 'Номер комнаты: 1'
print(,15); // Ошибка - можно опускать только параметры,
// начиная с конца их списка

В данном примере мы неудачно разместили параметры в списке. Удобнее в использовании будет, наверное, такая функция вывода на дисплей:

void display(int value = 1, char *name " "Номер дома:")
{ cout << "\n" << n
аmе << value; }

Обращения к ней могут быть такими:

display(); // Выводит: 'Номер дома: 1'
display(15); // Выводит: 'Номер дома: 15'
display(6,"Размерность пространства: "); // Выводит:
// 'Размерность пространства: 6'

6.2. Функции с переменным количеством параметров

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

тип имя (спецификация_явных_параметров,...);

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

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

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

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

//Р6-02.СРР - заданное количество необязательных параметров
#include < iostream.h > // Функция суммирует значения своих
// параметров типа int
long summa (int k, ...) // k - число суммируемых параметров
{int *pik = &k;
long total =0;
for(; k; k --) total += *(++pik);
return total;
}
void main()
{cout << "\n summa(2, 6, 4) = " << summa(2,6,4);
cout << "\n summa(6, 1, 2, 3, 4, 5, 6) = " <<
summa(6,l,2,3,4,5,6);
}

Результат выполнения программы:

summа(2, 6, 4) = 10
summa(6, 1, 2, 3, 4, 5, 6) = 21

Для доступа к списку параметров используется указатель pik типа int *. Вначале ему присваивается адрес явно заданного параметра k, т.е. он устанавливается на начало списка параметров в памяти (в стеке). Затем в цикле указатель pik перемещается по адресам следующих фактических параметров, соответствующих неявным формальным параметрам. С помощью разыменования *pik выполняется выборка их значений. Параметром цикла суммирования служит аргумент k, значение которого уменьшается на 1 после каждой итерации и, наконец, становится нулевым. Особенность функции - возможность работы только с целочисленными фактическими параметрами, так как указатель pik после обработки значения очередного параметра "перемещается вперед" на величину sizeof (int) и должен быть всегда установлен на начало следующего параметра.

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

//Р6-0З.СРР - индексация конца переменного списка
// параметров
#include < iostream.h >
// Функция вычисляет произведение параметров:
double prod(double acg, ... )
{ double aa = 1.0; // Формируемое произведение
double *prt = &arg; // Настроили указатель
/ / на первый параметр
if (*prt == 0.0) return 0.0;
for ( ; *prt; prt++) aa *= *prt;
return aa;
}
void main()
{ double prod(double, ...); // Прототип функции
cout << "\n prod(2e0, 4e0, 3e0, OeO) = " << prod(2e0,4e0,3e0,OeO);
cout << "\n prod(1.5, 2.0, 3.0, 0.0) = " << prod(1.5,2.0,3.0,0.0);
cout << "\n prod(1.4, 3.0, 0.0, 16.0, 84.3, 0.0) = ";
cout << prod(1.4,3.0,0.0,16.0,84.3,0.0);
cout << "\n prod(OeO) = " << prod(OeO);
}

Результат выполнения программы:

prod(2e0, 4e0, 3e0, OeO) = 24
prod(l.5, 2.0, 3.0, 0.0) = 9
prod(1.4, 3.0, 0.0, 16.0, 84.3, 0.0) = 4.2
prod(OeO) = 0

В функции prod перемещение указателя prt по списку фактических параметров выполняется всегда за счет изменения prt на величину sizeof (double). Поэтому все фактические параметры при обращении к функции prod () должны иметь тип double. В вызовах функции проиллюстрированы некоторые варианты задания параметров. Обратите внимание на вариант с нулевым значением параметра в середине списка. Параметры вслед за этим значением игнорируются.

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

//Р6-04.СРР - меняются тип и количество параметров функции
#include < iostream.h >
void main()
{ long minimum(char z, int k, ...); // Прототип функции
cout << "\n\tminimum('1', 3, 10L, 20L, 30L) = " <<
minimum('1',3,10L,20L,30L);
cout << "\n\tminimum('i', 4, 11, 2, 3, 4) = " <<
minimum ('i' ,4,11,2,3,4);
cout << "\n\tminimum('k', 2, 0, 64) = " <<
minimum ('k' ,2,0,64);
}
// Функция с переменным списком параметров
long minimum(char z, int k, ...)
{if (z == 'i')
{ int *pi = &k + 1; // Настроились на первый
// необязательный параметр
int min = *pi; // Значение первого
// необязательного параметра
for(; k; k--, pi++) min = min > *pi? *pi: min;
return (long)min;
}
if (z == 'i')
{ long *p1 = (long*)(&k+1);
long min в *p1; // Значение первого параметра
for(; k; k--, p1++) min = min > *p1? *p1 : min;
return (long)min;
}
cout << "\n0шибка! Неверно задан 1-й параметр:";
return 2222L;
}

Результат выполнения программы:

minimum('1', 3, 10L, 20L, 30L) = 10
minimum('i', 4, 11, 2, 3, 4) = 2
Ошибка! Неверно задан 1-й параметр:
minimum('k',2,0,64)=2222

В приведенных примерах функций с изменяемыми списками параметров перебор параметров выполнялся с использованием адресной арифметики и явным применением указателей нужных типов. К проиллюстрированному способу перехода от одного параметра к другому нужно относиться с осторожностью. Дело в том, что при обращении к функции ее параметры помещаются в стек, причем порядок их размещения в стеке зависит от реализации компилятора. Более того, в компиляторах имеются опции, позволяющие изменять последовательность помещения значений параметров в стек. Стандартная для языка Си++ последовательность размещения параметров в стеке предполагает, что первым обрабатывается и помещается в стек последний из параметров функции. При этом у него оказывается максимальный адрес (так стек устроен в реализациях на IBM PC). Противоположный порядок обработки и помещения в стек будет у функций, определенных и описанных с модификатором pascal. Этот модификатор и его антипод - модификатор cdecl являются дополнительными ключевыми словами, определенными для компиляторов ТС++ и ВС++. Не останавливаясь подробно на возможностях, предоставляемых модификатором pascal, отметим три факта. Во-первых, применение модификатора pascal необходимо в тех случаях, когда функция, написанная на языке Си или Си++, будет вызываться из программы, подготовленной на Паскале. Во-вторых, функция с модификатором pascal не может иметь переменного списка параметров, т.е. в ее определении и в ее прототипе нельзя использовать многоточие. Третий факт имеет отношение к разработке программ в среде Windows. Дело в том, что большинство из функций библиотеки API (Application Programming Interface - интерфейс прикладного программирования) для разработки приложений для младших версий системы Windows являются функциями, разработанными с использованием модификатора pascal.

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

Для обеспечения мобильности программ с функциями, имеющими изменяемые списки параметров, в каждый компилятор языка Си (и языка Си++) стандарт предлагает включать специальный набор макроопределений, которые становятся доступными при включении в текст программы заголовочного файла stdarg.h. Макрокоманды, обеспечивающие простой и стандартный (не зависящий от реализации) способ доступа к конкретным спискам фактических параметров переменной длины, имеют следующий формат:

void va_start (va_list param, последний_явный_параметр);
type va_arg (va_list param, type);
void va_end(va_list param);

Кроме перечисленных макросов, в файле stdarg.h определен специальный тип данных va_list, соответствующий потребностям обработки переменных списков параметров. Именно такого типа должны быть первые фактические параметры, используемые при обращении к макрокомандам va_atart(), va_arg(), va_end(). Объясним порядок использования перечисленных макроопределений в теле функции с переменным количеством параметров. Напомним, что каждая из функций с переменным количеством параметров должна иметь хотя бы один явно специфицированный формальный параметр, за которым после запятой стоит многоточие. В теле функции обязательно определяется объект типа va_list. Например, так:

va_list factor;

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

va_start (factor, последний_явный_параметр);

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

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

va_arg (factor, type)

позволяет, во-первых, получить значение очередного (вначале первого) фактического параметра типа type. Вторая задача макрокоманды va_arg() - заменить значение указателя factor на адрес следующего фактического параметра в списке. Теперь, узнав каким-то образом тип, например typel, этого следующего параметра, можно вновь обратиться к макросу:

va_arg (factor, type1)

Это обращение позволяет получить значение следующего фактического параметра и переадресовать указатель factor на фактический параметр, стоящий за ним в списке, и т.д.

Примечание. Реализация ТС++ и ВС++ запрещает [6] использовать с макрокомандой va_arg () типы char, unsigned char, float.

Макрокоманда va_end() предназначена для организации корректного возврата из функции с переменным списком параметров. Ее единственным параметром должен быть указатель типа va_list, который использовался в функции для перебора параметров. Таким образом, для наших рассуждений вызов макрокоманды должен иметь вид

va_end (factor);

Макрокоманда va_end() должна быть вызвана после того, как функция обработает весь список фактических параметров. Макрокоманда va end () обычно модифицирует свой аргумент (указатель типа va list), и поэтому его нельзя будет повторно использовать без предварительного вызова макроса va_start ().

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

//Р6-05.СРР - макросредства для переменного списка
// параметров
#include < iostream.h >
#include < string.h > // Для работы со строками
#include < stdarg.h > // Для макросредств
#include < stdlib.h > // Для функции malloc()
char *concat(char *s1,...)
{ va_ list par; // Указатель на параметры списка
char *cp = s1;
int len = strlen(s1); // Длина 1-го параметра
va_ start(par, s1); // Начало переменного списка
// Цикл для определения общей длины параметров-строк:
while (cp = va_arg(par, char *))
len += strlen(cp); // Выделение памяти для результата:
char *stroka = (char *)malloc(len + 1);
strcpy(stroka, s1);
va_start(par, sl); // Начало переменного списка
// Цикл конкатенации параметров строк :
while (cp = va_arg (par, char *))
strcat(stroka, cp); // Конкатенация двух строк
va_end(par);
return stroka;
}
void main()
( char* concat(char* s1, ...); // Прототип функции
char* s; // Указатель для результата
s = concat("\nNulla ", "Dies ", "Sine ", "Linea!", NULL);
s = concat(s, " - Ни одного дня без черточки!", "\n\t",
"(Плиний Старший о художнике Апеллесе)", NULL);
cout << s;
}

Результат выполнения программы:

Nulla Dies Sine Lineal - Ни одного дня без черточки!
(Плиний Старший о художнике Апеллесе)

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

printf(char* format, ...);
scanf(char* format, ...);

В обеих функциях форматная строка, связанная с указателем format, содержит спецификации преобразования (%d -для десятичных чисел, %е - для вещественных данных в форме с плавающей точкой, %f - для вещественных значений в форме с фиксированной точкой и т.д.). Кроме того, эта форматная строка в функции printf () может содержать произвольные символы, которые выводятся на дисплей без какого-либо преобразования. Чтобы продемонстрировать особенности построения функций с переменным числом параметров, классики языка Си [3] рекомендуют самостоятельно написать функцию, подобную функции printf (). Последуем их совету, применяя простейшие средства вывода языка Си++. Разрешим использовать только спецификации преобразования "%d" и "%f".

//Р6-06.СРР - упрощенный аналог printf()
//По мотивам K&R, [3], стр. 152
#include < iostream.h >
#include < stdarg.h > // Для макросредств переменного списка
// параметров
void miniprint(char *format, ...)
{ va_list ар; // Указатель на необязательней параметр
char *p; // Для просмотра строки format
int ii; // Целыe параметры
double dd; // Параметры типа
double va_ start(ар, format); // Настроились на первый параметр
for (p = format; *p; р++)
{if (*р!= '%')
{ cout << *р;
continue;
}
switch (*++p)
{case 'd' : ii = va_arg(ap,int);
cout << ii;
break;
case 'f': dd = va_ arg(ap,double);
cout << dd;
break;
default: cout << *p;
} // Конец переключателя
} // Конец цикла просмотра строки-формата
va_ end(ар); // Подготовка к завершению функции
}
void main()
{ void miniprint(char *, ...); // Прототип
int k = 154;
double e = 2.718282;
miniprint("\nЦелое k - %d,\tчислo e = %f", k, e);
}

Результат выполнения программы:

Целое k = 154,      число е = 2.718282

Интересной особенностью предложенной функции miniprint() и ее серьезных прародителей - библиотечных функций языка Си printf() и scanf () - является использование одного явного параметра и для задания типов последующих параметров, и для определения их количества. Для этого в строке, определяющей формат вывода, записывается последовательность спецификаций, каждая из которых начинается символом '%'. Количество спецификаций должно быть в точности равно количеству параметров в следующем за форматом списке. Конец обмена и перебора параметров определяется по достижению конца строки-формата.

6.3. Рекурсивные функции

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

Предварительно отметим, что различают прямую и косвенную рекурсии. Функция называется косвенно рекурсивной в том случае, если она содержит обращение к другой функции, содержащей прямой или косвенный вызов определяемой (первой) функции. В этом случае по тексту определения функции ее рекурсивность (косвенная) может быть не видна. Если в теле функции явно используется вызов этой функции, то имеет место прямая рекурсия, т.е. функция, по определению, рекурсивная (иначе - само вызываемая или само вызывающая: self-calling). Классический пример - функция для вычисления факториала неотрицательного целого числа.

long fact(int k) { if (k < 0) return 0;
if (k == 0) return 1;
return k * fact(k-l);
}

Для отрицательного аргумента результат по определению факториала не существует. В этом случае функция возвратит нулевое значение. Для нулевого параметра функция возвращает значение 1, так как, по определению. 0! равен 1. В противном случае вызывается та же функция с уменьшенным на 1 значением параметра и результат умножается на текущее значение параметра. Тем самым для положительного значения параметра k организуется вычисление произведения

k * (k-l) * (k-2) * ... *3*2*1*1

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

1 * fact(1-1)

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

double expo(double a, int n)
{if (n == 0) return 1;
if (a == 0) return 0;
if (n > 0) return a * expo(a, n-1);
if (n < 0) return expo(a, n+l) / a;
}

При обращении вида ехро (2.0, 3) рекурсивно выполняются вызовы функции ехро () с изменяющимся вторым аргументом: ехро (2.0,3), ехро (2.0,2), ехро (2.0,1), ехро (2.0,0). При этих вызовах последовательно вычисляется произведение

2.0 * 2.0 * 2.0 * 1

и формируется нужный результат.

Вызов функции для отрицательного значения степени, например, ехро (5. о, -2) эквивалентен вычислению выражения

ехро(5.0,0) / 5.0 / 5.0

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

Рекурсивный алгоритм можно применить для определения разбиений целых чисел. Разбиениями целого числа называют способы его представления в виде суммы целых чисел. Например, разбиениями числа 4 являются 4, 3 + 1, 2 + 2, 2 + 1 + 1, 1 + 1 + 1 + 1. Для подсчета числа различных разбиений произвольного целого N удобно воспользоваться вспомогательной функцией q(m,n), которая подсчитывает количество способов представления целого, а в виде суммы при условии, что каждое слагаемое не превосходит значения n. Определив такую функцию, можно вычислить число различных разбиений произвольного N как значение q(N,N). Функция q(m,n) при n == l или m == l должна возвращать значение 1. Если m <= n, то результат определяется выражением l + q (m, m-l). В противном случае, т.е. при m > n,q(m,n) равно сумме: q(m,n-l) + q(m-n,n). В соответствии с этими соотношениями определим "прямолинейную" и малоэффективную рекурсивную функцию:

int q(int m, int n)
{ if (m == 1 | | n == 1) return 1;
if (m <= n) return 1 + q(m, m-1);
return (q(m,n-l) +q(m-n,n));
}

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

В качестве еще одного примера рекурсии рассмотрим функцию определения с заданной точностью eрs корня уравнения f(х) = 0 на отрезке [a, b]. Предположим для простоты, что исходные данные задаются без ошибок, т.е. eps > 0, b > а, f(а) * f(b) < 0, и вопрос о возможности нескольких корней на отрезке [а, b] нас не интересует. Не очень эффективная рекурсивная функция для решения поставленной задачи содержится в следующей программе:

//Р6-07.СРР - рекурсия при определении корня математической
// функции
#include < iostream.h >
#include < math.h > // Для математических функций
#include < stdlib.h > // Для функции exit()
// Рекурсивная функция для поиска корня методом
// деления пополам:
double recRoot(double f(double), double a, double b,
double eps)
{ double fa = f(a), fb = f(b), c, fc;
if (fa * fb > 0)
{ cout << "\nНеверен интервал локализации корня!";
exit(1);
}
с = (а + b)/2.0;
fc = f(c);
if (fc == 0.0 || b - a < eps) return c;
return (fa *, fc < 0.0)? recRoot(f, a, c, eps):
racRoot(f, c, b, eps);
}
static int counter =0; // Счетчик обращений к тестовой
// функции
void main()
{ double root, А = 0.1, // Левая граница интервала
В = 3.5, // Правая граница интервала
EPS = 5е-5; // Точность локализации корня
double giper(double); // Прототип тестовой функции
root = recRoot(giper. A, B, EPS);
cout << "\nЧисло обращений к тестовой функции = " <<
counter;
cout << "\nКорень = " << root;
}
// Определение тестовой функции:
double giper(double x)
{ extern int counter;
counter++; // Счетчик обращений
return (2.0/x * cos(x/2.0));
}

Результат выполнения программы:

Число обращений к тестовой функции = 54
Корень = 3.141601

В рассматриваемой программе пришлось использовать библиотечную функцию exit (), специфицированную в заголовочном файле process. h Функция exit () позволяет завершить выполнение программы и возвращает операционной системе значение своего параметра.

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

6.4. Подставляемые (инлайн-) функции

Некоторые функции в языке Си++ можно определить с использованием специального служебного слова inline. Спецификатор inline позволяет определить функцию как встраиваемую, иначе говоря, подставляемую или "открыто подставляемую" [2], или "инлайн-функцию" [19]. Например, следующая функция определена как подставляемая:

inline float module(float х = 0, float у = 0)
{ return sqrt(х * х + у * у); }

Функция module() возвращает значение типа float, равное "расстоянию" от начала координат на плоскости до точки с координатами (х,у), определяемыми значениями фактических параметров. В теле функции вызывается библиотечная функция eqrt() для вычисления вещественного значения квадратного корня положительного аргумента. Так как подкоренное выражение в функции всегда неотрицательно, то специальных проверок не требуется. Обрабатывая каждый вызов встраиваемой функции, компилятор "пытается" подставить в текст программы код операторов ее тела. Спецификатор inline для функций, не принадлежащих классам (о последних будем говорить в связи с классами), определяет для функций внутреннее связывание. Во всех других отношениях подставляемая функция является обычной функцией, т.е. спецификатор inline в общем случае не влияет на результаты вызова функции, она имеет обычный синтаксис определения и описания, подчиняется всем правилам контроля типов и области действия. Однако вместо команд передачи управления единственному экземпляру тела функции компилятор в каждое место вызова функции помещает соответствующим образом настроенные команды кода операторов тела функции. Тем самым при многократных вызовах подставляемой функции размеры программы могут увеличиться, однако исключаются затраты на передачи управления к вызываемой функции и возвраты из нее. Как отмечает проект стандарта Си++, кроме экономии времени при выполнении программы, подстановка функции позволяет проводить оптимизацию ее кода в контексте, окружающем вызов, что в ином случае невозможно [2].

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

Так как компилятор встраивает код подставляемой функции вместо ее вызова, то определение функции со спецификатором inline должно находиться в том же модуле, что и обращение к ней, и размещается до первого вызова. Синтаксис языка не гарантирует обязательной подстановки кода функции для каждого вызова функции со спецификатором inline. Более того, "определение допустимости открытой подстановки функции в общем случае невозможно" [2]. Например, следующая функция, по-видимому, не может быть реализована как подставляемая даже в том случае, если она определена со спецификатором inline [2]:

inline void f()
{ char ch = 0;
if (cin >> ch && ch !=
'q') f();
}

Функция f() в зависимости от вводимого извне значения переменной ch либо просто возвращает управление, либо рекурсивно вызывает себя. Допустима ли для такой функции подстановка вместо стандартного механизма вызова? Это определяется реализацией...

Следующий случай, когда подстановка для функции со спецификатором inline проблематична, - вызов этой функции с помощью указателя на нее (т.е. с помощью ее адреса, см., например, п.6.6) Реализация такого вызова, как правило, будет выполняться с помощью стандартного механизма обращения к функции.

Проект стандарта [2] перечисляет следующие причины, по которым функция со спецификатором inline будет трактоваться как обычная функция (не подставляемая):

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

Ограничения на выполнение подстановки в основном зависят от реализации. В компиляторе ВС++ принято, что функция со спецификатором inline не должна быть рекурсивной, и не может содержать операторов for, while, do, switch, go to. При наличии таких служебных циклов в теле подставляемой функции компилятор выдает сообщение об ошибке.

Если же для функции со спецификатором inline компилятор не может выполнить подстановку из-за контекста, в который помещено обращение к ней, то функция считается статической (static) и выдается предупреждающее сообщение.

Хотя теоретически подставляемая функция ничем не отличается to результатам от обычной функции, существует несколько особенностей, которые следует учитывать, "подсказывая" компилятору с помощью спецификатора inline в определении функции целесообразность подстановок.

Так как порядок вычисления фактических параметров функций не определен синтаксисом языка Си++, то возможна различная реализация вычисления фактических параметров при обычном вызове функции и при ее подстановке.

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

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

6.5. Функции и массивы

Массивы могут быть параметрами функций, и функции могут возвращать указатель на массив в качестве результата. Рассмотрим эти возможности.

При передаче массивов через механизм параметров возникает задача определения в теле функции количества элементов массива, использованного в качестве фактического параметра. При работе со строками, т.е. с массивами типа char[], последний элемент каждого из которых имеет значение ' \0 ', затруднений практически нет. Анализируется каждый элемент, пока не встретится символ ' \0 ', и это считается концом строки-массива. В следующей программе введена функция len () для определения длины строки, передаваемой в функцию с помощью параметра:

//Р6-08.СРР - массивы-строки в качестве параметров
#include < iostream.h > // Для ввода-вывода
int ten(char e[])
{int m = 0;
while (e [m++]);
return m - 1;
}
void main()
{ char E[] = "Pro Теmроге! "; // "Своевременно" (лат.)
cout << "\nДлина строки \"Рго Теmроге!\" равна " <<
lеn(Е);
}

Результат выполнения программы:

Длина строки "Pro Tempore!" равна 12

В функции ten () строка-параметр представлена как массив, и обращение к его элементам выполняется с помощью явного индексирования.

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

//Р6-09.СРР - одномерные массивы в качества параметров
#include < iostream.h > // Для ввода-вывода
#include < math.h > // Для математических функций
float cosinus(int n, float x[], float y[])
{float a = 0, b = 0, с = 0;
for (int i = 0; i < n; i++)
{a += x[i] * y[i];
b += x[i] * x[i];
с += y[i] * y[i];
}
return a/sqrt(double(b * c));
}
void main()
{ float E[] = {1, 1, 1, 1, 1, 1, 1};
float G[] = {-1, -1, -1, -1, -1, -1, -1};
cout << "\nКосинус = " << cosinus(7, E, G);
}

Результат выполнения программы:

Косинус = -1

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

//Р6-10.СРР - указатели на одномерные массивы в качестве
//
параметров
#include < iostream.h >
void max_vect(int n, int *x, int *y, int *z)
{ for (int i = 0; i < n; i++)
z[i] = x[i] > y[i] ? x[i] ; y[i];
}
void main()
{ int a[] ={1, 2, 3, 4, 5, 6, 7};
int b[] = {7, 6, 5, 4, 3, 2, 1};
int c[7];
max_vect(7,a,b,c);
for (int i = 0; i < 7; i++)
cout << "\t" << c[i];
}

Результат выполнения программы:

7     6     5     4      5     6     7

Как и в функции cosinus(), параметр int n служит для определения размеров массивов-параметров.

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

//Р6-11.СРР - функция, возвращающая указатель на массив
#include < iostream.h >
// Функция "слияния" двух упорядоченных массивов
int *fusion(int n, int* а, int n, int* b)
{ int *х = new int[n + m]; // Массив с результатом
int ia = 0, ib = 0, ix = 0;
while (ia < n && ib < m) // Цикл до конца одного из
// массивов
if (a[ia] >b[ib]) x[ix++]=b[ib++];
else x[ix++] = a[ia++];
if (ia >= n) // Переписан массив а[]
while (ib <