В общем случае выражения с указателями подчиняются тем же правилам, что и обычные выражения. В этом разделе рассматривается применение указательных выражений в операциях присваивания, преобразования типов, а также в операциях «указательной» арифметики.
Присваивание указателей
Указатель можно использовать в правой части оператора присваивания для присваивания его значения другому указателю. Если оба указателя имеют один и тот же тип, то выполняется простое присваивание, без преобразования типа. В следующем примере
#include <stdio.h>
int main(void)
{
int x = 99;
int *p1, *p2;
p1 = &x;
p2 = p1;
/* печать значение x дважды */
printf("Значение по адресу p1 и p2: %d %d\n", *p1, *p2);
/* печать адреса x дважды */
printf("Значение указателей p1 и p2: %p %p", p1, p2);
return 0;
}
после присваивания
p1 = &x;
p2 = p1;
оба указателя (p1 и р2) ссылаются на х. То есть, оба указателя ссылаются на один и тот же объект. Программа выводит на экран следующее:
Значения по адресу p1 и р2 : 99 99
Значения указателей p1 и р2: 0063FDF0 0063FDF0
Обратите внимание, для вывода значений указателей в функции printf() используется спецификатор формата %р, который выводит адреса в формате, используемом компилятором.
Допускается присваивание указателя одного типа указателю другого типа. Однако для этого необходимо выполнить явное преобразование типа указателя (операция приведения типов), которая рассматривается в следующем разделе.
Преобразование типа указателя
Указатель можно преобразовать к другому типу. Эти преобразования бывают двух видов: с использованием указателя типа void * и без его использования.
В языке С допускается присваивание указателя типа void * указателю любого другого типа (и наоборот) без явного преобразования типа указателя. Тип указателя void * используется, если тип объекта неизвестен. Например, использование типа void * в качестве параметра функции позволяет передавать в функцию указатель на объект любого типа, при этом сообщение об ошибке не генерируется. Также он полезен для ссылки на произвольный участок памяти, независимо от размещенных там объектов. Например, функция размещения mallocO (рассматривается далее в этой главе) возвращает значение типа void *, что позволяет использовать ее для размещения в памяти объектов любого типа.
В отличие от void *, преобразования всех остальных типов указателей должны быть всегда явными (т.е. должна быть указана операция приведения типов). Однако следует учитывать, что преобразование одного типа указателя к другому может вызвать непредсказуемое поведение программы. Например, в следующей программе делается попытка присвоить значение х переменной у посредством указателя р. При компиляции программы сообщение об ошибке не генерируется, однако результат работы программы неверен.
#include <stdio.h>
int main(void)
{
double x = 100.1, y;
int *p;
/* В следующем операторе указателю на целое p
(присваивается значение, ссылающееся на double. */
p = (int *) &x;
/* Следующий оператор работает не так, как ожидается. */
y = *p; /* attempt to assign y the value x through p */
/* Следующий оператор не выведет число 100.1. */
printf("Значение x равно: %f (Это не так!)", y);
return 0;
}
Обратите внимание на то, что операция приведения типов применяется в операторе присваивания адреса переменной х (он имеет тип double *) указателю p, тип которого int *. Преобразование типа выполнено корректно, однако программа работает не так, как ожидается (по крайней мере, в большинстве оболочек). Для разъяснения проблемы предположим, что переменная int занимает в памяти 4 байта, а double — 8 байтов. Указатель p объявлен как указатель на целую переменную (т.е. типа int), поэтому оператор присваивания
y = *р;
передаст переменной y только 4 байта информации, а не 8 байтов, необходимых для double. Несмотря на то, что p ссылается на объект double, оператор присваивания выполнит действие с объектом типа int, потому что p объявлен как указатель на int. Поэтому такое использование указателя p неправильное.
Приведенный пример подтверждает то, что операции с указателями выполняются в зависимости от базового типа указателей. Синтаксически допускается ссылка на объект с типом, отличным от типа указателя, однако при этом указатель будет «думать», что он ссылается на объект своего типа. Таким образом, операции с указателями управляются типом указателя, а не типом объекта, на который он ссылается.
Разрешен еще один тип преобразований: преобразование целого в указатель и наоборот. В этом случае необходимо применить операцию приведения типов (явное преобразование типа). Однако пользоваться этим средством нужно очень осторожно, потому что при этом легко получить непредсказуемое поведение программы. Явное преобразование типа не обязательно, если преобразуется нуль, то есть нулевой указатель.
На заметку | В языке C++ требуется явно указывать преобразование типа указателей, в том числе указателей типа void *. Поэтому многие программисты используют в языке С явное преобразование для совместимости с C++. |
Адресная арифметика
В языке С допустимы только две арифметические операции над указателями: суммирование и вычитание. Предположим, текущее значение указателя p1 типа int * равно 2000. Предположим также, что переменная типа int занимает в памяти 2 байта. Тогда после операции увеличения
p1++;
указатель p1 принимает значение 2002, а не 2001. То есть, при увеличении на 1 указатель p1 будет ссылаться на следующее целое число. Это же справедливо и для операции уменьшения. Например, если p1 равно 2000, то после выполнения оператора
p1--;
значение p1 будет равно 1998.
Операции адресной арифметики подчиняются следующим правилам. После выполнения операции увеличения над указателем, данный указатель будет ссылаться на следующий объект своего базового типа. После выполнения операции уменьшения — на предыдущий объект. Применительно к указателям на char, операций адресной арифметики выполняются как обычные арифметические операции, потому что длина объекта char всегда равна 1. Для всех указателей адрес увеличивается или уменьшается на величину, равную размеру объекта того типа, на который они указывают. Поэтому указатель всегда ссылается на объект с типом, тождественным базовому типу указателя. Эта концепция иллюстрируется с помощью рис. 5.2.
char *ch = (char *) 3000;
int *i = (int *) 3000;
+------+
ch --->| 3000 |--.
+------+ |<- i
ch+1 ->| 3001 |--'
+------+
ch+2 ->| 3002 |--.
+------+ |<- i+1
ch+3 ->| 3003 |--'
+------+
ch+4 ->| 3004 |--.
+------+ |<- i+2
ch+5 ->| 3005 |--'
+------+
Память
Рис. 5.2. Пример размещения в памяти переменных char (слева) и int (справа)
Операции адресной арифметики не ограничены увеличением (инкрементом) и уменьшением (декрементом). Например, к указателям можно добавлять целые числа или вычитать из них целые числа. Выполнение оператора
p1 = p1 + 12;
«передвигает» указатель p1 на 12 объектов в сторону увеличения адресов.
Кроме суммирования и вычитания указателя и целого, разрешена еще только одна операция адресной арифметики: можно вычитать два указателя. Благодаря этому можно определить количество объектов, расположенных между адресами, на которые указывают данные два указателя; правда, при этом считается, что тип объектов совпадает с базовым типом указателей. Все остальные арифметические операции запрещены. А именно: нельзя делить и умножать указатели, суммировать два указателя, выполнять над указателями побитовые операции, суммировать указатель со значениями, имеющими тип float или double и т.д.
Сравнение указателей
Стандартом С допускается сравнение двух указателей. Например, если объявлены два указателя р и q, то следующий оператор является правильным:
if(p < q) printf("p ссылается на меньший адрес, чем q\n");
Как правило, сравнение указателей может оказаться полезным, только тогда, когда два указателя ссылаются на общий объект, например, на массив. В качестве примера рассмотрим программу с двумя стековыми функциями, предназначенными для записи и считывания целых чисел. Стек — это список, использующий систему доступа «первым вошел — последним вышел». Иногда стек сравнивают со стопкой тарелок на столе: первая, поставленная на стол, будет взята последней. Стеки часто используются в компиляторах, интерпретаторах, программах обработки крупноформатных таблиц и в других системных программах. Для создания стека необходимы две функции: push() и pop(). Функция push() заносит числа в стек, a pop() — извлекает их. В данном примере эти функции используются в main(). При вводе числа с клавиатуры, программа помещает его в стек. Если ввести 0, то число извлекается из стека. Программа завершает работу при вводе -1.
#include <stdio.h>
#include <stdlib.h>
#define SIZE 50
void push(int i);
int pop(void);
int *tos, *p1, stack[SIZE];
int main(void)
{
int value;
tos = stack; /* tos ссылается на основание стека */
p1 = stack; /* инициализация p1 */
do {
printf("Введите значение: ");
scanf("%d", &value);
if(value != 0) push(value);
else printf("значение на вершине равно %d\n", pop());
} while(value != -1);
return 0;
}
void push(int i)
{
p1++;
if(p1 == (tos+SIZE)) {
printf("Переполнение стека.\n");
exit(1);
}
*p1 = i;
}
int pop(void)
{
if(p1 == tos) {
printf("Стек пуст.\n");
exit(1);
}
p1--;
return *(p1+1);
}
Стек хранится в массиве stack. Сначала указатели p1 и tos устанавливаются на первый элемент массива stack. В дальнейшем p1 ссылается на верхний элемент стека, a tos продолжает хранить адрес основания стека. После инициализации стека используются функции push() и pop(). Они выполняют запись в стек и считывание из него, проверяя каждый раз соблюдение границы стека. В функции push() проверяется, что указатель p1 не превышает верхней границы стека tos+SIZE. Это предотвращает переполнение стека. В функции pop() проверяется, что указатель p1 не выходит за нижнюю границу стека.
В операторе return функции pop() скобки необходимы потому, что без них оператор
return *p1+1;
вернул бы значение, расположенное по адресу p1, увеличенное на 1, а не значение по адресу p1+1.