Вы находитесь на странице: 1из 19

17.

Указатели

17.1. Определение указателей


Как говорилось выше, машинная память состоит из байт. Все
байты в памяти пронумерованы. Адресом байта называется его
номер. (Нумерация при этом идет либо в пределах всей машинной
памяти, либо (чаще) в пределах выделенной программе области
памяти - сегмента). Если переменная (или массив) занимают
несколько или много байт, эти байты всегда идут подряд, и адресом
переменной считается номер ее первого байта.
При обработке декларации любой переменной, например
double x=1.5; компилятор выделяет для переменной участок памяти,
размер которого определяется ее типом (double – 8 байт) и
инициализирует его указанным значением (если таковое
присутствует). Далее все обращения в программе к переменной по
ее имени заменяются компилятором на адрес участка памяти, в
котором будет храниться значение этой переменной.
Разработчик программы на языке Си имеет возможность
определить собственные переменные для хранения адресов
участков оперативной памяти. Такие переменные называются
указателями.
Итак, указатель – это переменная, которая может содержать
адрес некоторого объекта. Простейшая декларация указателя имеет
формат:
тип * ID_указателя;
Например: int *a; double *f; char *w;
Здесь тип может быть любым, кроме ссылки или битового
поля; причем тип может быть к этому моменту только
декларирован, но еще не определен (следовательно, в структуре,
например, может присутствовать указатель на структуру того же
типа).
Символ «звездочка» относится непосредственно к ID
указателя, поэтому для того, чтобы декларировать несколько
указателей, ее нужно записывать перед именем каждого из них.
Например, в декларации:
int *a, *b, с;
определены два указателя на участки памяти для
целочисленных данных, а также обычная целочисленная
переменная с.
Значение указателя равно номеру первого байта участка
памяти, на который он ссылается. В C++ Builder'е под размещение
указателя отводится 4 байта, что позволяет использовать сегменты
памяти размером до 232 байт = 4 Гбайт.
Указатель не является самостоятельным типом данных, так
как всегда связан с каким-либо конкретным типом - т.е. указатель
содержит адрес области памяти, в которой хранятся данные
определенного типа. В языке Cи имеются три вида указателей –
указатели на объект известного типа, указатель типа void и
указатель на функцию. Эти три вида отличаются как своими
свойствами, так и набором допустимых операций.
Указатель типа void применяется в тех случаях, когда
конкретный тип объекта, адрес которого требуется хранить, не
определен (например, если в одном и том же указателе в разные
моменты времени требуется хранить адреса объектов различных
типов).
Указателю типа void можно присвоить значение указателя
любого типа, а также сравнивать его с любыми другими
указателями, но перед выполнением каких-либо действий с
участком памяти, на который он ссылается, требуется явно
преобразовать его к конкретному типу.
Указатель может быть константой или переменной, а также
указывать на константу или переменную.
С указателями связаны две унарные операции & и *.
Операция & означает «взять адрес» операнда. Операция *
имеет смысл – «значение, расположенное по указанному адресу»
(операция разадресации).
Таким образом, обращение к объектам любого типа как
операндам операций в языке Cи может производиться:
– по имени (идентификатору);
– по указателю (операция косвенной адресации):

ID_указателя = &ID_объекта; -операция разыменования;


*ID_указателя - операция косвенной адресации.

Операция разадресации, или разыменования, предназначена


для доступа к величине, адрес которой хранится в указателе. Эту
операцию можно использовать как для получения, так и для
изменения значения величины (если она не объявлена как
константа).
Унарная операция получения адреса & применима к
переменным, имеющим имя (ID), для которых выделены участки
оперативной памяти. Таким образом, нельзя получить адрес
скалярного выражения, неименованной константы или регистровой
переменной (типа register).
Говорят, что использование указателя означает отказ от
именования адресуемого им объекта. Отказ от именования
объектов при наличии возможности доступа по указателю
приближает язык Си по гибкости отображения "объект – память" к
языку ассемблера.

Пример 1:
int x, – переменная типа int ;
*y; – указатель на объект типа int;
y = &x; – y – адрес переменной x;
*y=1; – косвенная адресация указателем поля x, т.е. по
указанному адресу записать 1: x = 1.

Пример 2:
int i, j=8,k=5, *y;
y=&i;
*y=2; // i=2
y=&j;
*y+=i; // j+=i  j=j+i  j=j+2=10
y=&k;
k+=*y; // k+=k  k=k+k = 10
(*y)++; // k++  k=k+1 = 10+1 = 11

Указателю-переменной можно присвоить значение другого


указателя, либо выражения типа указатель с использованием, при
необходимости, операции приведения типа. Приведение типа
необязательно, если изменяемый ("стоящий слева" в операции = )
указатель имеет тип "void *".

int i,*x;
char *y;
x=&i; // x  величина типа int
y=(char *)x; // y  величина типа char
y=(char *)&i; // y  величина типа char

Значение указателя можно вывести на экран с помощью


спецификации %p (pointer), результат выводится в
шестнадцатеричном виде.

17.2. Связь указателей и массивов.


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

int a[5];
a
a[0] a[1] a[2] a[3] a[4]
4000 4004 4008 4012 4016
Здесь приведено символическое изображение оперативной
памяти, выделенной компилятором для объявленного
целочисленного массива а[5]. Адрес массива выбирается
компилятором в зависимости от размера доступной памяти,
наличия других переменных и массивов и др. Для конкретности,
здесь положено значение адреса, равное 4000. В реальной
программе вместо 4000 может быть другое значение, но
относительное положение элементов массива всегда остается
постоянным.
В языке С идентификаторы массивов считаются
константными указателями (т.е. в данном примере а "имеет
значение" 4000). Такую константу можно присвоить переменной
типа указатель, но нельзя подвергать преобразованиям, например:

int a[5], *q;


q = a; //Правильно - присваивание константы переменной
a = q; // Ошибка: в левой части - указатель-константа

Именно потому, что имена массивов считаются


константными указателями, в языке Си нельзя непосредственно
присваивать массивы друг другу (хотя структуры, включающие
массивы как поля, целиком присваивать друг другу можно!)
Однако операция sizeof для массивов все же дает размер
массива, а не указателя:

int n = sizeof(a) / sizeof(*a);


// n=5, т.к. sizeof(a)=20, sizeof(int)=4

int m = sizeof(q) / sizeof(*q);


// m=1, т.к. sizeof(int*)=4, sizeof(int)=4

17.3. Операции над указателями (косвенная адресация)


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

Допускается также вычитание указателей:


p1-p2
где p1, p2 - указатели. Результатом вычитания является целое
число.

Чтобы программист не был вынужден при этом каждый раз


учитывать размер элемента, в языке Си принято правило: все
арифметические операции с указателями выполняются в
единицах памяти того типа объекта, на который ссылается
этот указатель. Иными словами, операция p++ означает реальное
увеличение p не на единицу, а на sizeof(*p) ; при этом p как раз
будет указывать на следующий элемент массива. Аналогично,
выражение p+i означает в действительности p+ i*sizeof(*p) , т.е.
смещение на i элементов.
Заметим, что из-за этого указатели на объекты разных
типов, первоначально равные, могут стать неравными при
прибавлении к ним одной и той же величины:

int a[5], *q=a; // i указывает на a[0]


double *d=(double*)q;
// Теперь d=q (не считая разницы в типах)
q++; d++;
// теперь d>q, т.к. хранимый в d адрес
// увеличился на 8, а хранимый в q - на 4
q++; // а теперь снова d=q, и равно &a[2]

Однако на практике подобная адресация одного и того же


участка памяти указателями разных типов редко имеет смысл.
В силу сказанного выше, адрес i-го элемента массива A всегда
можно записывать и как &A[i], и как A+i . Итак, для массивов
запись A[i] и *(A+i) эквивалентна. Для удобства операций с
указателями, в языке С введено такое же правило записи и для них:
p[i] равносильно *(p+i)
где p - указатель, i - целочисленное выражение.
Иными словами, для обращения к i-му (считая от места, куда
указывает p) элементу массива вместо записи *(p+i) можно писать
короче: p[i]. Соответственно, и для указателей, и для массивов
запись *p эквивалентна p[0]
Пример:

int a[5], *q=a; // Инициализация q: q указывает на a[0]


//(Здесь * перед q означает объявление его типа(указатель), а не
разадресацию)
q++;
*q=40; // Означает a[1]=40;
q[3]=70; // Означает a[4]=70;
q[-1]=22; // Означает a[0]=22;
Операции с указателями бывают особенно полезны для
массивов char. (Напомним, что в любом месте, где допустима
строка как массив char, допустим также указатель на char). С их
помощью можно, например, обратиться к середине строки:

char s[]="Hello, world!";


cout<<s+7; // Будет выведен текст: world!

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


При сравнении указателей могут использоваться отношения
любого вида (">", ">=", "<", "<=", "==", "!="). Наиболее важными
видами проверок являются отношения равенства или неравенства.
Остальные отношения порядка имеют смысл только для указателей
на последовательно размещенные объекты (элементы одного
массива).
Разность двух указателей дает число объектов адресуемого
ими типа в соответствующем диапазоне адресов. Очевидно, что
уменьшаемый и вычитаемый указатель также должны
соответствовать одному массиву, иначе результат операции не
имеет практической ценности.
Любой указатель можно сравнивать со специальным
значением NULL, которое означает недействительный адрес
(адрес, по которому не размещается никакая переменная или
другой объект). Значение NULL можно присваивать указателю как
признак пустого указателя. NULL заменяется препроцессором на
выражение (void *)0.

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


Указатели, как и переменные любого другого типа, могут
объединяться в массивы. Объявление массива указателей на целые
числа имеет вид:
int *b[10], y;
Теперь каждому из элементов массива можно присвоить адрес
целочисленной переменной y, например: b[1]=&y;
Чтобы теперь найти значение переменной y через данный
элемент массива а, необходимо записать *b[1].
17.6. Указатели на указатели.
В языке Си можно описать и переменную типа «указатель на
указатель». Это ячейка оперативной памяти, в которой будет
храниться адрес указателя на какую либо переменную. Признак
такого типа данных – повторение символа «*» перед
идентификатором переменной. Количество символов «*»
определяет уровень вложенности указателей друг в друга. При
объявлении указателей на указатели возможна их одновременная
инициализация. Например:
int y=5;
int *p1=&y;
int **pp1=&p1;
int ***ppp1=&pp1;
Теперь присвоим целочисленной переменной y новое значение,
например 10. Одинаковое присваивание произведут следующие
операции:
y=10; *p1=10; **pp1=10; ***ppp1=10;
Для доступа к области ОП, отведенной под переменную y можно
использовать и индексы. Справедливы следующие аналоги:
*p1  p1[0] **pp1  pp1[0][0] ***ppp1  ppp1[0][0]
[0]

17.7 . Указатели как параметры функций.


В языке Си аргументы при стандартном вызове функции
передаются по значению. Это означает, что в стеке, как и в случае
локальных данных, выделяется место для формальных параметров
функции. В это место при вызове функции заносятся значения
фактических аргументов, при этом проверяется соответствие типов
и при необходимости выполняются их преобразования. При
несоответствии типов выдается диагностическое сообщение. Затем
функция использует и может изменять эти значения в стеке.
При выходе из функции измененные значения теряются, т.к.
время жизни и область видимости локальных параметров
ограничены кодом функции. Вызванная функция не может
изменить значения переменных, указанных как фактические
аргументы при обращении к данной функции.
Тем не менее, в случае необходимости функцию всё-таки
можно использовать для изменения ее аргументов. Но для этого
необходимо передавать в вызываемую функцию не значения
аргументов, а их адреса.
При передаче по адресу в стек заносятся адреса аргументов, а
функция осуществляет доступ к ячейкам памяти по этим адресам и
может изменить значения самих аргументов. Для обращения к
значению аргумента-оригинала используется операция «*».
Пример функции, в которой меняются местами значения x и y:

void zam(int *x, int *y)


{
int t = *x;
*x = *y;
*y = t;
}

Участок программы с обращением к данной функции:

void zam (int*, int*);


void main (void)
{
int a=2, b=3;
printf(" a = %d , b = %d\n", a, b);
zam (&a, &b);
printf(" a = %d , b = %d\n", a, b);
}

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


будут изменены, т.е. на экран монитора будет выведено:
a = 2 , b = 3
a = 3 , b = 2

Если требуется запретить изменение значений, адресуемых


каким-либо параметром внутри функции, то в его декларации
используют атрибут const, например:
void f1(int, const double *);
Рекомендуется указывать const перед всеми параметрами -
указателями, для которых в функции не предусмотрено изменение
значений, на которые они ссылаются. Это облегчает, например,
отладку программы, т.к. по заголовку функции видно, какие данные
в функции изменяются, а какие нет.

17.8. Ссылка
Ссылка - это не особый тип данных, а "автоматически
разыменуемый" указатель, т.е. это объект, который указывает на
положение другой переменной.
Ссылка - это "неявный" указатель, который отличается от
обычного "явного" указателя тем, что для ссылки не нужно
специально записывать операцию разадресации (она
подразумевается автоматически). Над указателями возможны
арифметические операции. Над самой ссылкой арифметические
операции невозможны (потому что они будут истолкованы
компилятором как операции над тем объектом, на который
ссылается ссылка).
Ссылка декларируется следующим образом:
type &ID = инициализатор;
Инициализатор - это идентификатор объекта, на который в
дальнейшем будет указывать ссылка. Пример:
int a = 8;
int &r = a;
Ссылка стала "псевдонимом" объекта, указанного в качестве
инициализатора. В данном примере, одинаковыми будут
следующие действия:
a++;
r++;
Наиболее полезны ссылки оказываются в качестве параметров
функций, т.к. значение (конкретный адрес) им тогда присваивается
при вызове функции. Например, вышеприведенный пример с
функцией zam тогда можно переписать так:
void zam(int &x, int &y)
{
int t = x;
x = y;
y = t;
}

Участок программы с обращением к данной функции:


void zam (int&, int&);
void main (void)
{
int a=2, b=3;
printf(" a = %d , b = %d\n", a, b);
zam (a, b);
printf(" a = %d , b = %d\n", a, b);
}

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


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

17.9. Указатели на функции


В языке Си допускаются указатели не только на данные, но и
на функции. Они позволяют, например, создать функцию,
строящую таблицу значений любой другой функции (с заданным
видом списка параметров); при этом конкретный вызов этой другой
функции осуществляется через указатель на функцию.
Рассмотрим методику работы с указателями на функции.
1. Как и любой объект языка Си, указатель на функции
необходимо декларировать. Формат объявления указателя на
функции следующий:
тип (*переменная-указатель)(список параметров);
т.е. декларируется указатель, который можно устанавливать
на функции, возвращающие результат указанного типа и которые
имеют указанный список параметров. Наличие первых круглых
скобок обязательно, так как без них – это декларация функции,
которая возвращает указатель.
Например, объявление вида:
double (*p_f )(char, double);
говорит о том, что декларируется указатель p_f, который
можно устанавливать на функции, возвращающие результат типа
double и имеющие два параметра: первый – символьного типа, а
второй – вещественного типа.

2. Идентификатор функции считается константным


указателем, поэтому для того, чтобы установить переменную-
указатель на конкретную функцию, достаточно ей присвоить ее
идентификатор:
переменная-указатель = ID_функции;
Например, имеется функция с прототипом: double f1(char,
double); тогда операция
p_f = f1;
установит указатель p_f на данную функцию.
Идентификатор функции может быть присвоен указателю и
по-другому:

double f1(char C, double D){


... // тело функции f1
}

void FunOut(double (*p_f )(char, double)){


... // тело функции FunOut
}

void main(){
... // тело функции main
FunOut(f1);
... // тело функции main
}

Здесь при вызове функции FunOut в нее передается аргумент -


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

3. Вызов функции после установки на нее указателя выглядит


так:
(*переменная-указатель)(список аргументов);
или
переменная-указатель (список аргументов);
После таких действий кроме стандартного обращения к
функции:
ID_функции(список аргументов);
появляются еще два способа вызова функции:
(*переменная-указатель)(список аргументов);
или
переменная-указатель (список аргументов);
Последняя запись справедлива, так как p_f также является
адресом начала функции в оперативной памяти.
Например, в теле вышеприведенной функции FunOut вызов
функции по указателю может выглядеть так:

double zz=p_f('@', 3.14159);

В вышеприведенном примере тогда будет вызвана функция f1,


причем C будет присвоено значение - символ @ , а D - 3.14159 .
18. Работа с динамической памятью

18.1. Динамическое выделение и освобождение памяти.


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

Для работы с динамической памятью в С++ введены две


операции:
захват памяти - new,
освобождение захваченной ранее памяти - delete.

18.2. Создание одномерного динамического массива.


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

Формат операции new для массивов:


указатель = new тип [количество] ;
Результат операции new, присваиваемый указателю –
адрес начала области памяти для размещения данных указанного
количества и типа. При нехватке памяти – результат NULL.

Формат операции delete:


delete указатель;
либо: delete[] указатель; (эти записи равносильны).
После выделения памяти с этим массивом можно работать,
аналогично работе с обычным массивом, используя
вышеописанную операцию косвенной адресации [].
Пример:
...
double *x;
int i, n;
...
puts(" Введите размер массива: ");
scanf(“%d”, &n);
x = new double [n] ;
if (x == NULL) {
puts(" Предел размерности ! ");
return;
}
for (i=0; i<n; i++) // Ввод элементов массива
scanf(“%lf”, &x[i]); // Косвенная адресация
...
delete [ ]x; // Освобождение памяти
...

18.3. Создание двуxмерного динамического массива.


Операция new способна выделить память лишь под
одномерный массив. А как быть, если массив двумерный?
Наиболее удобный способ - это представить двумерный
массив как массив из массивов, т.е каждую строку матрицы - как
одномерный массив. При этом под каждую строку матрицы память
будет выделена по отдельности, как под одномерный массив; и на
нее потребуется отдельный указатель. Поскольку строк в матрице
может быть много, указателей тоже будет много, и уже для их
хранения потребуется вспомогательный массив из указателей. А
так как количество строк в матрице переменного размера заранее
неизвестно, то этот вспомогательный массив тоже будет
динамическим, и сначала нужно выделить память под него.
Декларировать при этом придется только указатель на
вспомогательный массив; он будет, таким образом, указателем на
указатель (**).

Пример. Создать динамическую матрицу размера n1 х n2.


Присвоить каждому ее элементу значение, равное сумме номеров
его строки и столбца:
...
int **m, n1, n2;
puts("Vvedite chislo strok i stolbtsov");
scanf("%d%d", &n1, &n2);
m = new int * [n1]; // Захват памяти для массива указателей
// - на рисунке А (n1=3)
for ( int i=0; i<n1; i++) // Захват памяти для каждого из
m[i] = new int [n2]; // массивов элементов (n1 таких
// массивов)- на рисунке B (n2=4)
. . .
for ( i=0; i<n1; i++)
for ( j=0; j<n2; j++)
m[i][j] = i+j; // *(*(m+i)+j) = i+j;
. . .
for ( i=0; i<n1; i++) // Освобождение памяти
delete m[i];
delete m;
. . .

Устройство полученной "динамической матрицы" изображено


на Рис. 19.1. Здесь имеется вспомогательный массив из 3
указателей и 3 массива для хранения данных, в каждом - по 4
элемента. Указатель m указывает на массив указателей, а каждый
из указателей - на свою строку матрицы:

m зна чения

Ука- m[0]  m[0][0] m[0][1] m[0][2] m[0][3] m[i][j] ↔


за- *(*(m+i)+j)
тели m[1]  m[1][0] m[1][1] m[1][2] m[1][3]
m[2]  m[2][0] m[2][1] m[2][2] m[2][3]
(А) (В)
Рис. 4

Несмотря на сложность полученной схемы, работа с ней


предельно проста. Обращение к элементу матрицы имеет точно
такой же вид, как и к элементу "обычного" двумерного массива:
m[i][j]
а выделение и освобождение динамической памяти для
матрицы стандартно и не зависит от конкретной задачи:

m = new int * [n1]; // Выделение памяти


for ( int i=0; i<n1; i++)
m[i] = new int [n2];

. . .

for ( i=0; i<n1; i++) // Освобождение памяти


delete m[i];
delete m;

Преимущества динамических матриц:


1) Ее размер ограничен лишь размером доступной в данный момент
памяти;
2) Под динамическую матрицу выделяется не больше памяти, чем
требуется;
3) Ее строки могут быть не только одинаковой, но и разной длины
(если нужно);
4) Чтобы поменять местами ее строки, не обязательно выполнять
поэлементный обмен в цикле. Можно вместо этого
переприсвоить указатели:
int *w=m[0];
m[0]=m[1];
m[1]=w;

Хотя все элементы матрицы остались на месте, 0-ая и 1-ая


строки всё же поменялись местами, т.к. m[0] указывает теперь на
бывшую 1-ую строку (и наоборот), и значение m[0][i] будет таким,
каким до обмена было m[1][i].
19. Операция typedef

Любому типу данных, как стандартному, так и определенному


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

typedef unsigned int UINT; – здесь UINT – новое


имя для типа unsigned int;
typedef char M_s [101]; –здесь M_s – тип
пользователя, определяющий строки, длиной не более 100
символов.
Декларации объектов введенных типов будут иметь вид:
UINT i, j;  две переменные типа unsigned int ;
M_s str[10];  массив из 10 строк по 100 символов.

Рассмотренная операция упрощает использование указателей на


функции.