Создаём компьютерную игру
eng   рус

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

Предыдущий урок: Циклы (loops) и массивы (arrays) в C++
Следующий урок: Функции

В данном уроке мы рассмотрим два связанных понятия: указатели (pointers) и ссылки (references). Указатели позволяют работать с памятью напрямую. Это одна из отличительных черт C/C++. Указатели присутствуют в немногих языках, так как их использование считается небезопасным и может легко привести к многочисленным ошибкам. На этой мажорной ноте давайте начнём рассмотрение указателей.

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

Память и указатели в C++

Когда операционная система запускает программу на выполнение, она выделяет ей участок памяти, который называется куча (heap). У каждой переменной, которую вы создаёте, есть адрес в куче. C++ позволяет получить этот адрес - это просто порядковый номер. В шестнадцатеричной системе счисления он может выглядеть вот так:

0x000000fc3f0fcd74

На сегодняшний день адреса занимают 64 бита - 8 байт. Мы будем подробно разбирать разные архитектуры позже.

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

Указатель - это переменная, которая содержит адрес в памяти. Указатели имеют свой синтаксис:

int* p;

p - имя указателя (стандартный идентификатор С++). Звёздочка после типа говорит, что это именно указатель, а не простая переменная. Всё выражение int* читается как: указатель на тип int. При объявлении указателя он содержит случайный адрес, который может использоваться другими частями программы. Поэтому важно инициализировать указатели:

int* p1(nullptr); // или int* p2 = nullptr;

nullptr - специальное значение, которое позволяет определить содержит ли указатель правильный адрес. Поэтому мы можем использовать nullptr в проверке условия:

if (p == nullptr){ // указатель содержит nullptr - не имеет значения } // или так: if (!p) { // указатель содержит nullptr, в условии он возвращает false }

Типы переменных и указателей должны совпадать:

double* p2 (nullptr); char v2 = 1; p2 = v2; // так делать нельзя!

В последней строке компилятор выдаст ошибку. В будущем мы узнаем как в указателе на один тип хранить переменные разных типов - это очень мощный инструмент.

Оператор получения адреса & (address-of operator)

int* p1 (nullptr); int v1 = 5; p1 = &v1;

& перед именем переменной возвращает её адрес. Это унарный оператор получения адреса (address-of operator). В результате указатель p1 будет хранить адрес переменной v1. Если мы выведем содержимое указателя в консоль, то увидим адрес - это адрес первого байта переменной v1.

Получение значения - разыменование указателя (косвенный доступ)

Мы уже знаем как создать указатель и как поместить в него адрес какой-нибудь переменной, теперь пора узнать как получить значение в памяти на которую указывает указатель. Такая операция называется разыменованием (dereferencing) или косвенным обращением(indirection operator). В коде для разыменования используется звёздочка - *.

int v1 = 0; int* p1 = &v1; *p1 += 10; cout << v1 << endl; // на экране: 10 cout << *p1 << endl; // то же самое

*p1 разыменовывает указатель - программа позволяет получить доступ к памяти на которую ссылается указатель. Т.е. *p1 и v1 можно заменять друг другом.

Это, в общем-то, почти весь синтаксис для работы с указателями. Теперь самый главный вопрос - для чего они нужны? Большинство других языков не имеют указателей. Самое главное свойство указателей - они имеют прямой доступ к памяти. Это позволяет сделать программу на C/C++ очень быстрой. Быстрее будет только на ассемблере.

Указатели используются для работы с функциями и для динамического выделения памяти. Функции мы начнём рассматривать в следующем уроке. А пока рассмотрим динамическое выделение памяти и для чего это нужно.

Динамическое выделение памяти в C++ (Dynamic Memory Allocation)

Динамическое выделение памяти позволяет выделить произвольный участок памяти в куче во время выполнения программы. Мы почти всегда не знаем какого размера данные нам придётся обрабатывать, поэтому мы не можем заранее создать массивы необходимого размера. Например, наша программа загружает какие-то данные из файлов (простые текстовые файлы, 3д модели для игры...), в этом случае мы узнаем размер данных только когда "прочитаем" файл.

Давайте посмотрим как происходит динамическое выделение памяти:

int* p1(nullptr); p1 = new int; delete p1; int* p2 = new int(100); // или int* p2(new int(100)) delete p2;

С помощью ключевого слова new мы просим операционную систему выделить память под одну переменную int и возвращает адрес выделенного участка (адрес первого байта). Для указателя p2 мы ещё и инициализируем этот участок, помещая в него значение 100. Обратите внимание на синтаксис: ключевое слово new, имя типа и значение в круглых скобках. Ключевое слово delete освобождает (release) память - после этого нельзя пользоваться указателем. Давайте рассмотрим это подробнее:

Утечка памяти (Memory Leak)

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

int* p1 = new int(10); p1 = new int(20);

В первой строке выделяется память и адрес присваивается указателю p1. В следующей строке выделяется новый участок в памяти и уже адрес нового участка присваивается p1. В этом случае ваша программа теряет возможность получить доступ к первому участку памяти - операционная система пометила его как занятый, она не может использовать его как-либо, и в то же время ваша программа потеряла адрес - вы не можете освободить эту память. Произошла утечка памяти. Теперь представьте, в нашей программе постоянно выделяется память под очень большие массивы и мы постоянно теряем указатели - очень быстро программа займёт всю свободную оперативную память. Поэтому очень важно всегда освобождать память, когда выделенный участок больше не нужен.

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

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

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

Один из самых важных вариантов использования указателей - динамическое выделение памяти под массивы. Это делается очень просто:

int* p = new int[20];

После имени типа нужно поставить квадратные скобки и указать количество элементов под которое нужно выделить память. В данном примере будет выделено 80 байт. Освобождается память под массив тоже очень легко, нужно просто поставить квадратные скобки перед именем указателя:

delete [] p;

Указатели на тип void

Здесь нужно ещё упомянуть тип void и указатель на него. В следующих уроках мы встретим такой код:

void* p;

Это указатель на тип void. Но что это такое мы разберём в следующих уроках.

Ссылки (References)

Ссылки занимают промежуточный уровень между переменными и указателями. Большинство других языков использует именно механизм ссылок вместо указателей. Ссылка - это всего лишь псевдоним.

Существует два типа ссылок lvalue и rvalue. Пока что мы разберём только lvalue ссылки и ниже я буду называть их просто ссылками.

Ссылка указывает на участок памяти где уже определена переменная. В момент создания ссылки переменная уже должна быть определена. Ссылка создаётся таким образом:

int v1 (5); int& r1 (v1); // ссылка r1 на переменную v1 int& r2 (5); // ошибка!

После типа мы ставим символ &. Теперь ссылку можно использовать как переменную:

r1 = 10; cout << v1 << endl; // 10

За исключением создания, ссылка ведёт себя точно также как и переменная.

Для чего можно использовать ссылки? В прошлом уроке мы видели использование ссылок в цикле for на основе диапазона. Это не единственное применение. Но, как водится, другие способы мы узнаем в следующих уроках.

Операторы * и &

В этом уроке мы видели использование * и & в разных ситуациях. * использовался для создания указателей, для разыменования указателя (не забываем, что этот же символ используется в C/C++ и для умножения). & использовался для создания ссылок и для получения адреса переменных. Здесь мы видим так называемую перегрузку операторов - это когда один оператор выполняет разные действия. Компилятор выбирает нужное действие в зависимости от контекста. Перегрузке будет посвящён целый урок.

Заключение

В данном уроке мы рассмотрели указатели и ссылки. Хотя, мы не затронули некоторые более специфические темы: указатели на многомерные массивы, динамическое выделение памяти для многомерных массивов и rvalue ссылки.

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

Комментарии:

No comments yet