search

Указатели

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

При рассмотрении темы указателей нам придется использовать такие понятия, как размер базовых С++-типов данных. В этой главе мы предположим, что символы занимают в памяти один байт, целочисленные значения — четыре, значения с плавающей точкой типа float — четыре, а значения с плавающей точкой типа double — восемь (эти размеры характерны для типичной 32-разрядной среды).

Что представляют собой указатели

Указатели — это переменные, которые хранят адреса памяти. Чаще всего эти адреса обозначают местоположение в памяти других переменных. Например, если х содержит адрес переменной у, то о переменной, х говорят, что она "указывает" на у.

Указатель — это переменная, которая содержит адрес другой переменной. Переменные-указатели (или переменные типа указатель) должны быть соответственно объявлены. Формат объявления переменной-указателя таков:

Здесь элемент тип означает базовый тип указателя, причем он должен быть допустимым С++-типом. Элемент имя_переменной представляет собой имя переменной-указателя. Рассмотрим пример. Чтобы объявить переменную р указателем на int-значение, используйте следующую инструкцию.


тип *имя_переменной;

int *р;

Для объявления указателя на float-значение используйте такую инструкцию.


float *р;

В общем случае использование символа "звездочка" (*) перед именем переменной в инструкции объявления превращает эту переменную в указатель.

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

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


int *ip; // указатель на целочисленное значение

double *dp; // указатель на значение типа double

Как отмечено в комментариях, переменная ip — это указатель на int-значение, поскольку его базовым типом является тип int, а переменная dp — указатель на double-значение, поскольку его базовым типом является тип double, Следовательно, в предыдущих примерах переменную ip можно использовать для указания на int-значения, а переменную dp на double-значения. Однако помните: не существует реального средства, которое могло бы помешать указателю ссылаться на "бог-знает-что". Вот потому-то указатели потенциально опасны.

Операторы, используемые с указателями

С указателями используются два оператора: "*" и "&" Оператор "&" — унарный. Он возвращает адрес памяти, по которому расположен его операнд. (Вспомните: унарному оператору требуется только один операнд.) Например, при выполнении следующего фрагмента кода


balptr = &balance;

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

Второй оператор работы с указателями (*) служит дополнением к первому (&). Это также унарный оператор, но он обращается к значению переменной, расположенной по адресу, заданному его операндом. Другими словами, он ссылается на значение переменной, адресуемой заданным указателем. Если (продолжая работу с предыдущей инструкцией присваивания) переменная balptr содержит адрес переменной balance, то при выполнении инструкции


value = *balptr;

переменной value будет присвоено значение переменной balance, на которую указывает переменная balptr. Например, если переменная balance содержит значение 3200, после выполнения последней инструкции переменная value будет содержать значение 3200, поскольку это как раз то значение, которое хранится по адресу 100. Назначение оператора "*" можно выразить словосочетаинем "по адресу". В данном случае предыдущую инструкцию можно прочитать так: "переменная value получает значение (расположенное) по адресу balptr".


#include <iostream>
using namespace std;

int main()
{
 int balance;
 int *balptr;
 int value;
  balance = 3200;
  balptr = &balance;
  value = *balptr;
   cout << "Баланс равен:" << value <<'\n';
return 0;
}

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

Например, следующий фрагмент кода некорректен


int *р;
double f;
// ...
р = &f; // ОШИБКА!

теперь формально корректен.
int *р;
double f;
// ...
р = (int *) &f; // Теперь формально все ОК!

// Эта программа не будет выполняться правильно.
#include <iostream>
using namespace std;
int main()
{
double x, у;
int *p;
x = 123.23;
p = (int *) &x; // Используем операцию приведения типов для
присваивания double-указателя int-указателю.
у = *р; // Что происходит при выполнении этой инструкции?
cout << у; // Что выведет эта инструкция?
return 0;
}

Присваивание значений с помощью указателей


#include <iostream>
using namespace std;

int main()
{
 int *p, num;
 p = & num;
 *p = 100;
 cout << num << ' ';
 (*p)++;
 cout << num << ' ';
 (*p)--;
 cout << num << '\n';
return 0;
}
Вот такие результаты генерирует эта программа.
100 101 100

Использование указателей в выражениях

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

Арифметические операции над указателями

С указателями можно использовать только четыре арифметических оператора: ++, --, + и -. Чтобы лучше понять, что происходит при выполнении арифметических действий с указателями, начнем с примера. Пусть p1 — указатель на int-переменную с текущим значением 2 ООО (т.е. p1 содержит адрес 2 ООО). После выполнения (в 32-разрядной среде) выражения


p1++;

содержимое переменной-указателя p1 станет равным 2 004, а не 2 001! Дело в том, что при каждом инкрементировании указатель p1 будет указывать на следующее int-значение. Для операции декрементирования справедливо обратное утверждение, т.е. при каждом декрементировании значение p1 будет уменьшаться на 4. Например, после выполнения инструкции


p1--;

указатель p1 будет иметь значение 1 996, если до этого оно было равно 2 000. Итак, каждый раз, когда указатель инкрементируется, он будет указывать на область памяти, содержащую следующий элемент базового типа этого указателя. А при каждом декрементировании он будет указывать на область памяти, содержащую предыдущий элемент базового типа этого указателя.

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

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


p1 = p1 + 9;

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

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

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

Чтобы понять, как формируется результат выполнения арифметических операций над указателями, выполним следующую короткую программу. Она выводит реальные физические адреса, которые содержат указатель на int-значение (i) и указатель на floatзначение (f). Обратите внимание на каждое изменение адреса (зависящее от базового типа указателя), которое происходит при повторении цикла. (Для большинства 32-разрядных компиляторов значение i будет увеличиваться на 4, а значение f — на 8.) Отметьте также, что при использовании указателя в cout-инструкции его адрес автоматически отображается в формате адресации, применяемом для текущего процессора и среды выполнения.

// Демонстрация арифметических операций над указателями.

#include <iostream>
using namespace std;
int main()
{
int *i, j[10];
double *f, g[10];
int x;
i = j;
f = g;
for(x=0; x < 10; x++)
cout << i + x << ' ' << f + x << '\n';
return 0;
}

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


0012FE5C 0012FE84
0012FE60 0012FE8C
0012FE64 0012FE94
0012FE68 0012FE9C
0012FE6C 0012FEA4
0012FE70 0012FEAC
0012FE74 0012FEB4
0012FE78 0012FEBC
0012FE7C 0012FEC4
0012FE80 0012FECC

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

Сравнение указателей

Указатели можно сравнивать, используя операторы отношения ==, < и >. Однако для того, чтобы результат сравнения указателей поддавался интерпретации, сравниваемые указатели должны быть каким-то образом связаны. Например, если p1 и р2 — указатели, которые ссылаются на две отдельные и никак не связанные переменные, то любое сравнение p1 и р2 в общем случае не имеет смысла. Но если p1 и р2 указывают на переменные, между которыми существует некоторая связь (как, например, между элементами одного и того же массива), то результат сравнения указателей p1 и р2 может иметь определенный смысл. Ниже в этой главе мы рассмотрим пример программы, в которой используется сравнение указателей.

Указатели и массивы

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


char str[80];
char *p1;
p1 = str;

Здесь str представляет собой имя массива, содержащего 80 символов, a p1 — указатель на тип char. Особый интерес представляет третья строка, при выполнении которой переменной p1 присваивается адрес первого элемента массива str. (Другими словами, после этого присваивания p1 будет указывать на элемент str[0].) Дело в том, что в C++ использование имени массива без индекса генерирует указатель на первый элемент этого массива. Таким образом, при выполнении присваивания p1 = str адрес stг[0] присваивается указателю p1. Это и есть ключевой момент, который необходимо четко понимать: неиндексированное имя массива, использованное в выражении, означает указатель на начало этого массива.

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


str[4]
или
*(p1+4)

В обоих случаях будет выполнено обращение к пятому элементу. Помните, что индексирование массива начинается с нуля, поэтому при индексе, равном четырем, обеспечивается доступ к пятому элементу. Точно такой же эффект производит суммирование значения исходного указателя (p1) с числом 4, поскольку p1 указывает на первый элемент массива str.

Необходимость использования круглых скобок, в которые заключено выражение p1+4, обусловлена тем, что оператор "*" имеет более высокий приоритет, чем оператор "+". Без этих круглых скобок выражение бы свелось к получению значения, адресуемого указателем p1, т.е. значения первого элемента массива, которое затем было бы увеличено на 4.

Важно! Убедитесь лишний раз в правильности использования круглых скобок в выражении с указателями. В противном случае ошибку будет трудно отыскать, поскольку внешне программа может выглядеть вполне корректной. Если у вас есть сомнения в необходимости их использования, примите решение в их пользу — вреда от этого не будет

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

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


Это
лишь
простой
тест.

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

// Программа разбиения строки на слова:
// версия с использованием указателей.
#include  <iostream>
#include  <cstdio>
using namespace std;
int main()
{
char str[80];
char token[80];
char *p, *q;
cout << "Введите предложение: ";
gets(str);
p = str;
// Считываем лексему из строки.
while(*р) {
q = token; // Устанавливаем q для указания на начало массива
token.
/* Считываем символы до тех пор, пока не встретится либо
пробел, либо нулевой символ (признак завершения строки). */
while(*p != ' ' && *р) {
*q = *р;
q++; р++;
}
if(*p) р++; // Перемещаемся за пробел.
*q = '\0'; // Завершаем лексему нулевым символом.
cout << token << '\n';
}
return 0;
}

А вот как выглядит версия той же программы с использованием индексирования
массивов.

// Программа разбиения строки на слова:
// версия с использованием индексирования массивов.
#include <iostream>
#include  <cstdio>
using namespace std;
int main()
{
char str[80];
char token[80];
int i, j;
cout << "Введите предложение: ";
gets(str);
// Считываем лексему из строки.
for(i=0; ; i++) {
/* Считываем символы до тех пор пока не встретится либо
пробел, либо нулевой символ (признак завершения строки). */
for(j=0; str[i]!=' ' && str[i]; j++, i++)
token[j] = str[i];
token[j] = '\0'; // Завершаем лексему нулевым символом.
cout << token << '\n';
if(!str[i]) break;
}
return 0;
}

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

Индексирование указателя

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


// Индексирование указателя подобно массиву.
#include <iostream>
#include <cctype>
using namespace std;
int main()
{
char str[20] = "I love you";
char *p;
int i;
p = str;
// Индексируем указатель.
for(i=0; p[i]; i++) p[i] = toupper(p[i]);
cout << p; // Отображаем строку.
return 0;
}

При выполнении программа отобразит на экране следующее.
I LOVE YOU

Вот как работает эта программа. Сначала в массив str вводится строка "I love you". Затем адрес начала этой строки присваивается указателю р. После этого каждый символ строки str с помощью функции toupper() преобразуется в его прописной эквивалент посредством индексирования указателя р. Помните, что выражение р[i] по своему действию идентично выражению *(p+i).

О взаимозаменяемости указателей и массивов

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


int num[10];
int i;
for(i=0; i < 10; i++) {
*num = i; // Здесь все в порядке.
num++; // ОШИБКА — переменную num модифицировать нельзя.
}

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

Хотя имя массива генерирует константу-указатель, на него, тем не менее, (подобно указателям) можно включать в выражения, если, конечно, оно при этом не модифицируется. Например следующая инструкция, при выполнении которой элементу num[3] присваивается значение 100, вполне допустима.

*(num+3) = 100; // Здесь все в порядке, поскольку num не изменяется.

Массивы указателей

Указатели, подобно данным других типов, могут храниться в массивах. Вот, например, как выглядит объявление 10-элементного массива указателей на int-значения.


int *ipa[10];

Здесь каждый элемент массива ipa содержит указатель на целочисленное значение. Чтобы присвоить адрес int-переменной с именем var третьему элементу этого массива указателей, запишите следующее.


ipa[2] = &var;

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


x = *ipa[2];

Поскольку адрес переменной var хранится в элементе ipa[2], применение оператора "*" к этой индексированной переменной позволит получить значение переменной var

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


char *fortunes[] = {
"Вскоре деньги потекут к Вам рекой.\n",
"Вашу жизнь озарит новая любовь.\n",
"Вы будете жить долго и счастливо.\n",
"Деньги, вложенные сейчас в дело, принесут доход.\n",
"Близкий друг будет искать Вашего расположения.\n"
};

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


cout << fortunes[1];

Ниже программа предсказаний приведена целиком. Для получения случайных чисел используется функция rand(), а для получения случайных чисел в диапазоне от 0 до 4 — оператор деления по модулю, поскольку именно такие числа могут служить для доступа к элементам массива по индексу.


#include <iostream>
#include <cstdlib>
#include <conio.h>
using namespace std;
char *fortunes[] = {
"Вскоре деньги потекут к Вам рекой.\n",
"Вашу жизнь озарит новая любовь.\n",
"Вы будете жить долго и счастливо.\n",
"Деньги, вложенные сейчас в дело, принесут доход.\n",
"Близкий друг будет искать Вашего расположения.\n"
};
int main()
{
int chance;
cout <<"Чтобы узнать свою судьбу, нажмите любую клавишу: ";
// Рандомизируем генератор случайных чисел.
while(!kbhit()) rand();
cout << '\n';
chance = rand();
chance = chance % 5;
cout << fortunes[chance];
return 0;
}

Обратите внимание на цикл while, который вызывает функцию rand() до тех пор, пока не будет нажата какая-либо клавиша. Поскольку функция rand() всегда генерирует одну и ту же последовательность случайных чисел, важно иметь возможность программно использовать эту последовательность с некоторой произвольной позиции. (В противном случае каждый раз после запуска программа будет выдавать одно и то же "предсказание".) Эффект случайности достигается за счет повторяющихся обращений к функции rand(). Когда пользователь нажмет клавишу, цикл остановится на некоторой, случайной позиции последовательности генерируемых чисел, и эта позиция определит номер сообщения, которое будет выведено на экран. Напомню, что функция kbhit() представляет собой довольно распространенное расширение библиотеки функций C++, обеспечиваемое многими компиляторами, но не входит в стандартный пакет библиотечных функций C++.

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


// Простая памятка по ключевым словам C++.
#include <iostream>
#include <cstring>
using namespace std;
char *keyword[][2] = {
"for", "for(инициализация; условие; инкремент)",
"if", "if(условие) ... else ... ",
"switch", "switch(значение) {case-список}",
"while", "while(условие) ...",
// Сюда нужно добавить остальные ключевые слова C++.
"", "" // Список должен завершаться нулевыми строками.
};
int main()
{
char str[80];
int i;
cout << "Введите ключевое слово: ";
cin >> str;
// Отображаем синтаксис.
for(i=0; *keyword[i][0]; i++)
if(!strcmp(keyword[i][0], str))
cout << keyword[i][1];
return 0;
}

Вот пример выполнения этой программы.
Введите ключевое слово: for

for (инициализация; условие; инкремент)

В этой программе обратите внимание на выражение, управляющее циклом for. Оно приводит к завершению цикла, когда элемент keyword[i][0] содержит указатель на нуль, который интерпретируется как значение ЛОЖЬ. Следовательно, цикл останавливается, когда встречается нулевая строка, которая завершает массив указателей.

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

Для программиста нет ничего более страшного, чем "взбесившиеся" указатели! Указатели можно сравнить с энергией атома: они одновременно и чрезвычайно полезны и чрезвычайно опасны. Если проблема связана с получением указателем неверного значения, то такую ошибку отыскать труднее всего.

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

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