[Оглавление]
[Вниз]

Оператор безусловного перехода имеет вид:

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]:

Следование перечисленным рекомендациям позволяет исключить возможные нежелательные последствия бессистемного использования оператора безусловного перехода. Полностью отказываться от оператора 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, может содержать следующие коды:

С помощью введенного выше указателя С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.

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

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

Однако допустимо получать адрес именованной константы, т.е. правомерна, например, такая последовательность определений:

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], *

//Р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 - целые положительные константы, то последовательность действий компилятора такова:

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

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].

После такой инициализации массива указателей к элементам исходного массива появляются два пути доступа:

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

//Р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 имеются регистры сегментов:

Каждый сегментный регистр может содержать при выполнении программы конкретные значения сегментной части адреса. Таким образом, процессор одновременно может адресовать 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 группы:

Модификатор размещается при определении указателя непосредственно перед символом ' * ' и относится (как символ ' * ' и как инициализатор) только к одному указателю в определении.

Ближние указатели (пеа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].

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

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

//Р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 < m) x[ix++] = b[ib++];
else // Переписан массив b []
while (ia < n) x[ix++] = a[ia++];
return х;
}
void main(void)
{ int c[] - {1, 3, 5, 7, 9};
int d[] = { 0, 2, 4, 5 };
int *h; // Указатель для массива с результатом
int kc = sizeof(c)/sizeof(c[0]); // Количество элементов
// в с[]
int kd = sizeof(d)/sizeof(d[0]); // Количество элементов
// в d[]
h = fusion(kc, c, kd, d);
cout << "\nРезультат объединения массивов:\n";
for (int i = 0; i < kc + kd; i++)
cout << " " << h[i];
delete[] h;
}

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

Результат объединения массивов:
0     1     2     3     4     5     5     7      9

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

double prim[6][4][2];

то мы описываем не трехмерный массив, а одномерный массив с именем prim, включающий шесть элементов, каждый из которых имеет тип double[4] [2]. В свою очередь, каждый из этих элементов есть одномерный массив из четырех элементов типа double [2]. И, наконец, каждый из этих элементов является массивом из двух элементов типа double.

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

Наивное, неверное и очевидное решение - определить заголовок функции таким образом:

void transponir(double x[] [], int n) ...

Здесь n - предполагаемый порядок квадратной матрицы; double х[] []; - попытка определить двухмерный массив с заранее неизвестными размерами. На такую попытку транслятор отвечает гневным сообщением:

Error ...: Size of type is unknown or zero

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

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

//Р6-12.СРР - многомерный массив в качестве параметра
#include < iostream.h >
// Очень неудачная функция для транспонирования матриц
void transp (int n, float d[] [3])
{float r;
for (int i = 0; i < n - 1; i++)
for (int j = i + l; j < n; j++)
{r = d[i] [j];
d[i] [j] = d[j] [i];
d[j] [i] = r;
}
}
void main ()
{ float x[3] [3] = (0, 1, 1,
2, 0, 1,
2, 2, 0);
int n = 3;
transp (3,x);
for (int i = 0; i < n; i++) // Цикл перебора строк
{ cout << "\n строка " << (i+1) << " : ";
for (int j = 0; j < n; j++) // Цикл печати элементов
// строки cout << "\t" << x[i] [j];
}
}

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

строка 1: 0 2 2
строка 2: 1 0 2
строка 3: 1 1 0

Примитивность и нежизненность продемонстрированного решения состоят в том, что в функции transp () массив-параметр специфицирован с фиксированным вторым размером, т.е. транспонируемая квадратная матрица может быть только с размерами 3 на 3.

Указанные ограничения на возможность применения многомерных массивов в качестве параметров можно обойти несколькими путями. Первый путь - подмена многомерного массива одномерным и имитация внутри функции доступа к многомерному массиву. (Здесь будут полезны макроопределения с индексами в качестве параметров.) Второй путь -использование вспомогательных массивов указателей на массивы. Третий путь предусматривает применение классов для представления многомерных массивов. Об этом будет сказано позже.

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

Следующая программа иллюстрирует один из способов передачи в функцию информации о двухмерном массиве, размеры которого заранее неизвестны. Функция trans() выполняет транспонирование квадратной матрицы, определенной вне тела функции в виде двухмерного массива. Параметры функции: int n - порядок матрицы; double *p[] - массив указателей на одномерные массивы элементов типа double. В теле функции обращение к элементам обрабатываемого массива осуществляется с помощью двойного индексирования. Здесь p[i] - указатель на одномерный массив (на строку матрицы с элементами типа double), p[i] [j] -обращение к конкретному элементу двухмерного массива. Текст программы:

//Р6-13.СРР - вспомогательный массив указателей на массивы
#include < iostream.h >
// Функция транспонирования квадратных матриц
void trans(int n, double *p[])
{double x;
for (int i = 0; i < n - 1; i++)
for (int j = i + 1; j < n; j++)
{x = P[i] [j];
p[i] [j] = p [j] [i];
p [j] [i] = x;
}
}
void main()
{ // Заданный массив - матрица, подлежащая
// транспонированию:
double А[4] [4] = { 11, 12, 13, 14,
21, 22, 23, 24,
31, 32, 33, 34,
41, 42, 43, 44 };
// Вспомогательный одномерный массив указателей:
double *ptr[] = { (double *) &A[0], (double *) &A[l],
(double *) &A[2], (double *)&A[3] };
int n - 4;
trans(n, ptr);
// Печать результатов обработки матрицы:
for (int i = 0; i < n; i++) // Цикл перебора строк
{ cout << "\n строка " << (i+1) << " : ";
// Цикл печати элементов строки:
for (int j = О; j < n; j++)
cout << "\t" << A[i] [j];
}
}

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

строка 1:     11     21     31    41
строка 2:     12     22     32    42
строка 3:     13     23     33     43
строка 4:     14     24     34     44

В основной программе матрица представлена двухмерным массивом с фиксированными размерами double A[4][4]. Такой массив нельзя непосредственно использовать в качестве фактического параметра вместо формального параметра со спецификацией double *р[]. Поэтому вводится дополнительный вспомогательный массив указателей double *ptr []. В качестве начальных значений элементам этого массива присваиваются адреса строк матрицы, т.е. &А [О], &А[1], &А[2], &А[З], преобразованные к типу double*. Дальнейшее очевидно из текста программы.

В следующей программе матрица формируется в основной программе как совокупность одномерных динамических массивов строк матрицы и динамического массива указателей на эти массивы-строки. Элементы массива указателей имеют тип int*, с массивом указателей в целом связывается указатель int **pi. Для простоты опущены проверки правильности выделения памяти и выбрано фиксированное значение (m == 3) порядка матрицы. Функция fill () присваивает элементам квадратной матрицы значения "подряд": а[0] [0] = 0, а[0][1] = 1 ... и т.д.,т.е. а[i] [j] = (i * n) + j, где n- порядок матрицы. В иллюстративных целях указатель mat на массив указателей на строки матрицы специфицирован в заголовке без использования квадратных скобок: int** mat.

Первый из параметров функции fill () со спецификацией int n определяет размеры квадратной матрицы. Текст программы:

//Р6-14.СРР - матрица как набор одномерных массивов
#include < iostream.h >
// Функция, определяющая значения элементов матрицы
void fill(int n, int** mat)
{int k = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
mat[i] [j] = k++;
}
// Динамические массивы для представления матрицы
void main()
{int **pi; // Указатель на массив указателей
int m = 3; // Размеры массивов, т.е. порядок матрицы
pi = new int* [m]; // Вспомогательный массив указателей
for (int i = 0; i < m; i++)
pi[i] = new int [m]; // Формируем строки (одномерные
// массивы)
fill(m, pi); // Заполнение матрицы
for (i = 0; i < m; i++) // цикл перебора строк
{ cout << "\n строка " << (i+l) << " : ";
// Цикл печати элементов строки:
for (int j = 0; j < m; j++)
cout << "\t" << pi[i] [j];
}
for (i = 0; i < m; i++)
delete pi[i];
delete[] pi;
}

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

строка 1: 0 1 2
строка 2: 3 4 5
строка 3: 6 7 8

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

Многомерный массив с переменными размерами, сформированный в функции, непосредственно невозможно вернуть в вызывающую программу как результат выполнения функции. Однако возвращаемым функцией значением может быть указатель на одномерный массив указателей на одномерные массивы с элементами известной размерности и заданного типа. В следующей программе функция single_matr() возвращает именно такой указатель, так как имеет тип int **. В тексте функции формируется набор одномерных массивов с элементами типа int и создается массив указателей на эти одномерные массивы. Количество одномерных массивов и их длины определяются значением параметра функции, специфицированного как int n. Совокупность создаваемых динамических массивов представляет квадратную матрицу порядка п. Диагональным элементам матрицы присваиваются единичные значения, остальным - нулевые, т.е. матрица заполняется как единичная диагональная. Локализованный в функции single_ matr () указатель int** p "настраивается" на создаваемый динамический массив указателей и используется в операторе возврата из функции как возвращаемое значение. В основной программе вводится с клавиатуры желаемое значение порядка матрицы (int n), а после ее формирования печатается результат. Текст программы:

//Р6-15.СРР - единичная диагональная матрица с изменяемым
// порядком
#include < iostream.h > // Для ввода-вывода
#include < process.h > // Для exit()
// Функция, формирующая единичную матрицу:
int **single_matr(int n) // n - нужный размер матрицы
{ // Вспомогательный указатель на формируемую матрицу:
int** p;
// Массив указателей на строки - одномерные массивы:
р = new int* [n];

[Оглавление]
[Вверх]
 

Hosted by uCoz