Указатели и ссылки
В данном уроке мы рассмотрим два связанных понятия: указатели (pointers) и ссылки (references). Указатели позволяют работать с памятью напрямую. Это одна из отличительных черт C/C++. Указатели присутствуют в немногих языках, так как их использование считается небезопасным и может легко привести к многочисленным ошибкам. На этой мажорной ноте давайте начнём рассмотрение указателей.
Указатели достаточно сложная тема и их назначение мы сможем полностью понять только в следующих уроках, когда познакомимся с более сложной концепцией C++ - функциями.
Память и указатели в C++
Когда операционная система запускает программу на выполнение, она выделяет ей участок памяти, который называется куча (heap). У каждой переменной, которую вы создаёте, есть адрес в куче. C++ позволяет получить этот адрес - это просто порядковый номер. В шестнадцатеричной системе счисления он может выглядеть вот так:
На сегодняшний день адреса занимают 64 бита - 8 байт. Мы будем подробно разбирать разные архитектуры позже.
При создании переменных программа берёт какой-нибудь кусок в куче и присваивает ему имя (имя переменной). Когда вы в программе используете это имя, программа смотрит какому адресу принадлежит это имя.
Указатель - это переменная, которая содержит адрес в памяти. Указатели имеют свой синтаксис:
p - имя указателя (стандартный идентификатор С++). Звёздочка после типа говорит, что это именно указатель, а не простая переменная. Всё выражение int* читается как: указатель на тип int. При объявлении указателя он содержит случайный адрес, который может использоваться другими частями программы. Поэтому важно инициализировать указатели:
nullptr - специальное значение, которое позволяет определить содержит ли указатель правильный адрес. Поэтому мы можем использовать nullptr в проверке условия:
Типы переменных и указателей должны совпадать:
В последней строке компилятор выдаст ошибку. В будущем мы узнаем как в указателе на один тип хранить переменные разных типов - это очень мощный инструмент.
Оператор получения адреса & (address-of operator)
& перед именем переменной возвращает её адрес. Это унарный оператор получения адреса (address-of operator). В результате указатель p1 будет хранить адрес переменной v1. Если мы выведем содержимое указателя в консоль, то увидим адрес - это адрес первого байта переменной v1.
Получение значения - разыменование указателя (косвенный доступ)
Мы уже знаем как создать указатель и как поместить в него адрес какой-нибудь переменной, теперь пора узнать как получить значение в памяти на которую указывает указатель. Такая операция называется разыменованием (dereferencing) или косвенным обращением(indirection operator). В коде для разыменования используется звёздочка - *.
*p1 разыменовывает указатель - программа позволяет получить доступ к памяти на которую ссылается указатель. Т.е. *p1 и v1 можно заменять друг другом.
Это, в общем-то, почти весь синтаксис для работы с указателями. Теперь самый главный вопрос - для чего они нужны? Большинство других языков не имеют указателей. Самое главное свойство указателей - они имеют прямой доступ к памяти. Это позволяет сделать программу на C/C++ очень быстрой. Быстрее будет только на ассемблере.
Указатели используются для работы с функциями и для динамического выделения памяти. Функции мы начнём рассматривать в следующем уроке. А пока рассмотрим динамическое выделение памяти и для чего это нужно.
Динамическое выделение памяти в C++ (Dynamic Memory Allocation)
Динамическое выделение памяти позволяет выделить произвольный участок памяти в куче во время выполнения программы. Мы почти всегда не знаем какого размера данные нам придётся обрабатывать, поэтому мы не можем заранее создать массивы необходимого размера. Например, наша программа загружает какие-то данные из файлов (простые текстовые файлы, 3д модели для игры...), в этом случае мы узнаем размер данных только когда "прочитаем" файл.
Давайте посмотрим как происходит динамическое выделение памяти:
С помощью ключевого слова new мы просим операционную систему выделить память под одну переменную int и возвращает адрес выделенного участка (адрес первого байта). Для указателя p2 мы ещё и инициализируем этот участок, помещая в него значение 100. Обратите внимание на синтаксис: ключевое слово new, имя типа и значение в круглых скобках. Ключевое слово delete освобождает (release) память - после этого нельзя пользоваться указателем. Давайте рассмотрим это подробнее:
Утечка памяти (Memory Leak)
В самом начале урока я упомянул, что указатели считаются небезопасными. Рассмотрим, что такое утечка памяти. Она происходит в следующей ситуации:
В первой строке выделяется память и адрес присваивается указателю p1. В следующей строке выделяется новый участок в памяти и уже адрес нового участка присваивается p1. В этом случае ваша программа теряет возможность получить доступ к первому участку памяти - операционная система пометила его как занятый, она не может использовать его как-либо, и в то же время ваша программа потеряла адрес - вы не можете освободить эту память. Произошла утечка памяти. Теперь представьте, в нашей программе постоянно выделяется память под очень большие массивы и мы постоянно теряем указатели - очень быстро программа займёт всю свободную оперативную память. Поэтому очень важно всегда освобождать память, когда выделенный участок больше не нужен.
После освобождения памяти указатель становится висячим (dangling pointer) и его использование может привести к ошибкам. Поэтому, после освобождения, желательно присвоить указателю nullptr, тогда вы не сможете случайно обратиться к нему.
В более поздних уроках мы рассмотрим, как можно решить проблему утечки памяти с помощью умных указателей (smart pointers).
Динамическое выделение памяти для массивов
Один из самых важных вариантов использования указателей - динамическое выделение памяти под массивы. Это делается очень просто:
После имени типа нужно поставить квадратные скобки и указать количество элементов под которое нужно выделить память. В данном примере будет выделено 80 байт. Освобождается память под массив тоже очень легко, нужно просто поставить квадратные скобки перед именем указателя:
Указатели на тип void
Здесь нужно ещё упомянуть тип void и указатель на него. В следующих уроках мы встретим такой код:
Это указатель на тип void. Но что это такое мы разберём в следующих уроках.
Ссылки (References)
Ссылки занимают промежуточный уровень между переменными и указателями. Большинство других языков использует именно механизм ссылок вместо указателей. Ссылка - это всего лишь псевдоним.
Существует два типа ссылок lvalue и rvalue. Пока что мы разберём только lvalue ссылки и ниже я буду называть их просто ссылками.
Ссылка указывает на участок памяти где уже определена переменная. В момент создания ссылки переменная уже должна быть определена. Ссылка создаётся таким образом:
После типа мы ставим символ &. Теперь ссылку можно использовать как переменную:
За исключением создания, ссылка ведёт себя точно также как и переменная.
Для чего можно использовать ссылки? В прошлом уроке мы видели использование ссылок в цикле for на основе диапазона. Это не единственное применение. Но, как водится, другие способы мы узнаем в следующих уроках.
Операторы * и &
В этом уроке мы видели использование * и & в разных ситуациях. * использовался для создания указателей, для разыменования указателя (не забываем, что этот же символ используется в C/C++ и для умножения). & использовался для создания ссылок и для получения адреса переменных. Здесь мы видим так называемую перегрузку операторов - это когда один оператор выполняет разные действия. Компилятор выбирает нужное действие в зависимости от контекста. Перегрузке будет посвящён целый урок.
Заключение
В данном уроке мы рассмотрели указатели и ссылки. Хотя, мы не затронули некоторые более специфические темы: указатели на многомерные массивы, динамическое выделение памяти для многомерных массивов и rvalue ссылки.
В следующем уроке мы начнём знакомство с функциями и увидим использование указателей и ссылок на практике.