Лекция № 7

Тема: «Динамическое выделение памяти и указатели»

 

План лекции

1.                  Указатели. Разыменование указателей

2.                  Инициализация указателей

3.                  Арифметика указателей

4.                  Связь между указателями и массивами

 

1.                  Указатели. Разыменование указателей

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

Определение переменной number

 

int number = 1024;

 

заставляет компилятор выделить в памяти область, достаточную для хранения переменной типа int, связать с этой областью имя number и поместить туда значение 1024. Все это делается на этапе компиляции, до выполнения программы. С объектом number ассоциируются две величины: собственно значение переменной, 1024 в данном случае, и адрес той области памяти, где хранится это значение. Мы можем обращаться к любой из этих двух величин. Когда мы пишем:

 

int number2 = number + 1;

 

то обращаемся к значению, содержащемуся в переменной number: прибавляем к нему 1 и инициализируем переменную number2 этим новым значением, 1025.

Каким же образом обратиться к адресу, по которому размещена переменная?
С++ имеет встроенный тип “указатель”, который используется для хранения адресов объектов.

Указатель – переменная, которая хранит адрес другой переменной определенного типа.

Чтобы объявить указатель, содержащий адрес переменной number, мы должны написать:

 

int *pnumber; // указатель на объект типа int

 

Существует также специальная операция взятия адреса, обозначаемая символом &. Ее результатом является адрес объекта. Следующий оператор присваивает указателю pnumber адрес переменной number:

 

int *pnumber;

pnumber = &number; // pnumber получает значение адреса number

 

Пример. Просмотрим адреса переменных, используемых в программах.

 

int main()

{          int a=12;                   

float b=13.7;

            cout<<"Адрес переменной a="<<&a<<endl;

            cout<<"Адрес переменной b="<<&b<<endl;

            return(0);

}

Результат:

 

Пример.

 

int main()

{

            int number=99;

            int* pnumber=&number;

            cout<<"Адрес переменной - "<<pnumber<<endl;

            return(0);

}

 

Мы можем обратиться к тому объекту, адрес которого содержит pnumber (number в нашем случае), используя операцию разыменования, называемую также косвенной адресацией. Эта операция обозначается символом *. Вот как можно косвенно прибавить единицу к number, используя ее адрес:

 

*pnumber = *pnumber + 1; // неявно увеличивает number

 

Это выражение производит в точности те же действия, что и

 

number = number + 1; // явно увеличивает number

 

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

Основные отличия между статическим и динамическим выделением памяти таковы:

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

·                     выделение и освобождение памяти под статические объекты производится компилятором автоматически. Программисту не нужно самому заботиться об этом. Выделение и освобождение памяти под динамические объекты целиком и полностью возлагается на программиста. Это достаточно сложная задача, при решении которой легко наделать ошибок. Для манипуляции динамически выделяемой памятью служат операторы new и delete.

 

2.                  Инициализация указателей

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

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

Инициализатор записывается после имени указателя либо в круглых скобках, либо после знака равенства. 

 

Способы инициализации указателя: 

 

1. Присваивание указателю адреса существующего объекта: 

• с помощью операции получения адреса:

1

2

3

int а = 5; //целая переменная

int* р = &а; //в указатель записывается адрес а

int* р (&а); //то же самое другим способом

• с помощью значения другого инициализированного указателя:

int* r = р;

• с помощью имени массива или функции, которые трактуются как адрес:

1

2

3

4

5

6

int b[10]; //массив

int* t = b; //адрес начала массива

.......

void f(int а){/ * ... */} //определение функции

void (*pf)(int); //указатель на функцию

pf = f; //присваивание адреса функции

 

2. Присваивание указателю адреса области памяти в явном виде:

char* vp = (char *)0хВ8000000:

Здесь 0хВ8000000 — шестнадцатеричная константа, (char *) — операция приведения типа: константа преобразуется к типу "указатель на char".

 

3. Присваивание пустого значения:

1

2

int* SUXX = NULL;

int* rulez = 0;

Замечание. Использовать обычный 0, так как это значение типа int будет правильно преобразовано стандартными способами. 

 

Пример. Использование указателей

 

#include <iostream.h>

#include <iomanip.h>

int main()

{

            system("chcp 1251>nul");

            //Объявление и инициализация указателя

            int* pnumber=NULL;

            int number1=55, number2=99;

            //Сохранение адреса переменной  number1 в указателе 

            pnumber=&number1;

            cout<<"Адрес переменной number1 = "<<pnumber<<endl;

            cout<<"Содержимое переменной number1 через указатель = "<<*pnumber<<endl;

            //Увеличиваем number1 на  11 ---- 1 способ

            number1+=11;                     

            cout<<"Содержимое переменной number1 через указатель = "<<*pnumber<<endl;

            //Увеличиваем number1 на  10 ---- 2 способ

            *pnumber+=10;                   

            cout<<"Содержимое переменной number1 через указатель = "<<*pnumber<<endl<<endl;

            //Изменяем адрес указателя

            pnumber=&number2;

            cout<<"Адрес переменной number2 = "<<pnumber<<endl;

            cout<<" Содержимое переменной number2 через указатель = "<<*pnumber<<endl<<endl;

            //Умножаем number2 на 10 и записываем в  number1

            number1=*pnumber * 10;

            cout<<"Адрес переменной number1 = "<<&number1<<endl;

            cout<<"Значение number1 = "<<number1<<endl;       

            cout<<" Адрес, хранящийся в указателе pnumber = "<<pnumber<<endl;

            cout<<"Содержимое через указатель = "<<*pnumber<<endl;

            system("pause");

            return(0);

}

 

3.                  Арифметика указателей

С указателями можно выполнять следующие операции:

·                 сложение указателя и целого числа, результат - указатель;

·                 увеличение или уменьшение переменной типа указатель, что эквивалентно прибавлению или вычитанию единицы;

·                 вычитание двух указателей, результат - целое число.

Прибавление к указателю p целого числа n означает увеличение адреса, который содержится в переменной p, на суммарный размер n элементов того типа, на который ссылается указатель. Указатель как бы сдвигается на n элементов вправо, если считать, что индексы элементов массива возрастают слева направо. Аналогично вычитание целого числа n из указателя означает сдвиг указателя влево на n элементов. Пример:

 

int *p, *q;

int a[100];

p = &(a[5]); // записываем в p адрес 5-го

             //     элемента массива a

p += 7;      // p будет содержать адрес 12-го эл-та

q = &(a[10]);

--q;         // q содержит адрес элемента a[9]

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

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

 

int *p, *q;

int a[100];

int n;

p = &(a[5]);

q = &(a[12]);

n = q - p;      // n == 7

q = p + n;      // q == &(a[12])

 

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

 

int *p, *q, *r;

int a[100];

p = &(a[5]);

q = &(a[12]);

r = p + q;  // Ошибка! Указатели нельзя складывать.

 

4. Связь между указателями и массивами

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

При наличии объявления

 

double* pmas;                       //объявление указателя

double mas[10];         //объявление массива из 10 элементов

 

можно применить присвоение

pmas=mas4;               //инициализация указателя адресом массива

 

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

 

pmas = &pmas[2];

Здесь указатель pmas получает адрес третьего элемента массива.

 

В языке Си имя массива a является указателем на его первый элемент, т.е. выражения a и &(a[0]) эквивалентны. Учитывая арифметику указателей, получаем эквивалентность следующих выражений:

 

a[i]  ~  *(a+i)

 

Действительно, при прибавлении к a целого числа i происходит сдвиг на i элементов вправо. Поскольку имя массива является адресом его начального элемента, получается адрес i -го элемента массива a. Применяя операцию звездочка *, получаем сам элемент a[i]. Точно так же эквивалентны выражения

 

&(a[i])  ~  a+i   // (адрес эл-та a[i]).

 

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

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

 

p[i]  ~  *(p+i)

 

Таким образом, выбор между массивами и указателями - это выбор между двумя эквивалентными способами записи программ. Указатели, возможно, нравятся системным программистам, которые привыкли к работе с адресами объектов. Массивы больше отвечают традиционному стилю. В объектно-ориентированных языках, таких как Java или C#, указателей либо нет вовсе, либо их разрешено использовать лишь в специфических ситуациях. Массивы же присутствуют в подавляющем большинстве алгоритмических языков.

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

 

double a[100], s=0;

int i;

...

i = 0;

 

while (i < 100) {

s += a[i];

++i;

}

double a[100], s=0;

double *p, *g;

...

p = a;  // адрес начала массива

g = a+100;  // адрес за концом

while (p < g) {

s += *p;

++p;

}

 

5. Выделение памяти

Оператор new имеет две формы. Первая форма выделяет память под единичный объект определенного типа:

int *pnumber = new int(1024);

Здесь оператор new выделяет память под безымянный объект типа int, инициализирует его значением 1024 и возвращает адрес созданного объекта. Этот адрес используется для инициализации указателя pnumber. Все действия над таким безымянным объектом производятся путем разыменовывания данного указателя, т.к. явно манипулировать динамическим объектом невозможно.
Вторая форма оператора new выделяет память под массив заданного размера, состоящий из элементов определенного типа:

int *pmas = new int[4];

В этом примере память выделяется под массив из четырех элементов типа int. К сожалению, данная форма оператора new не позволяет инициализировать элементы массива.
Некоторую путаницу вносит то, что обе формы оператора new возвращают одинаковый указатель, в нашем примере это указатель на целое. И pnumber, и p
mas объявлены совершенно одинаково, однако pnumber указывает на единственный объект типа int, а pmas – на первый элемент массива из четырех объектов типа int.

Когда динамический объект больше не нужен, мы должны явным образом освободить отведенную под него память. Это делается с помощью оператора delete, имеющего, как и new, две формы – для единичного объекта и для массива:

// освобождение единичного объекта

delete pnumber;

// освобождение массива

delete[] pmas;

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

 

Выполните самостоятельно:

Упражнение 1. Объясните разницу между четырьмя объектами:

(a) int number = 10;

(b) int *pnumber = &number;

(c) int *pnumber2 = new int(10);

(d) int *pmas = new int[10];

 

Упражнение 2. Что делает следующий фрагмент кода?

int *pnumber = new int(5);

int *pmas = new int[10];

while ( *pnumber < 10 )

{

   pmas[*pnumber] = *pnumber;

  *pnumber = *pnumber + 1;
}

delete pnumber;

delete[] pmas;

 

Контрольные вопросы

1.      Что означает статическое размещение объектов в памяти?

2.      Дайте понятие динамической памяти?

3.      Назовите основные отличия между статическим и динамическим выделением памяти.

4.      Что называют указателем?

5.      Раскройте понятие операции взятия адреса.

6.      Что такое разыменование указателей?

7.      Назовите способы инициализации указателей.

8.      Что такое масштабирование в программировании?

9.      Что будет в результате, если выполнить сложение указателя и целого числа?

10.  Какой будет результат, если произойдет увеличение или уменьшение переменной типа указатель на единицу?

11.  Что будет в результате вычитания двух указателей?

12.  Какая связь между указателями и массивами?