10.8 – Знакомство с указателями

Добавлено 7 июня 2021 в 03:26
Глава 10 – Массивы, строки, указатели и ссылки  (содержание)

В уроке «1.3 – Знакомство с переменными в C++», мы отметили, что переменная – это имя части памяти, которая содержит значение. Когда наша программа создает экземпляр переменной, ей автоматически присваивается адрес свободной памяти, и любое значение, которое мы присваиваем переменной, сохраняется в памяти с этим адресом.

Например:

int x{};

Когда эта инструкция выполняется процессором, будет выделена часть памяти из ОЗУ. В качестве примера предположим, что переменной x присвоена ячейка памяти 140. Всякий раз, когда программа видит переменную x в выражении или инструкции, она знает, что она должна искать значение в ячейке памяти 140.

Что хорошо в переменных, так это то, что нам не нужно беспокоиться о том, какой конкретный адрес памяти назначен. Мы просто ссылаемся на переменную по ее заданному идентификатору, и компилятор переводит это имя в соответствующий адрес памяти.

Однако у этого подхода есть некоторые ограничения, которые мы обсудим в этом и будущих уроках.

Оператор адреса (&)

Оператор адреса (&) позволяет нам увидеть, какой адрес памяти назначен переменной. Это довольно просто:

#include <iostream>
 
int main()
{
    int x{ 5 };
    std::cout << x << '\n';  // выводим значение переменной x
    std::cout << &x << '\n'; // выводим адрес памяти переменной x
 
    return 0;
}

На машине автора показанная выше программа напечатала:

5
0027FEA0

Примечание. Хотя оператор адреса выглядит так же, как оператор побитового И, их можно различить, поскольку оператор адреса является унарным, тогда как оператор побитового И является бинарным.

Оператор косвенного обращения (*)

Получение адреса переменной само по себе не очень полезно.

Оператор косвенного обращения (*) (также называемый оператором разыменования) позволяет нам получить доступ к значению по определенному адресу:

#include <iostream>
 
int main()
{
    int x{ 5 };
    std::cout << x << '\n';     // выводим значение переменной x
    std::cout << &x << '\n';    // выводим адрес памяти переменной x
    std::cout << *(&x) << '\n'; // выводим значение по адресу памяти переменной x 
                                // (скобки не требуются, но упрощают чтение)
 
    return 0;
}

На машине автора показанная выше программа напечатала:

5
0027FEA0
5

Примечание. Хотя оператор косвенного обращения выглядит так же, как оператор умножения, вы можете различить их, поскольку оператор косвенного обращения является унарным, а оператор умножения – бинарным.

Указатели

Теперь, когда в наши инструменты добавлены оператор адреса и оператор косвенного обращения, мы можем поговорить об указателях. Указатель – это переменная, которая в качестве значения хранит адрес памяти.

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

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

Переменные-указатели объявляются так же, как обычные переменные, только со звездочкой между типом данных и именем переменной. Обратите внимание, что эта звездочка не является косвенным обращением. Это часть синтаксиса объявления указателя.

int *iPtr{};    // указатель на значение типа int
double *dPtr{}; // указатель на значение типа double
 
int* iPtr2{};  // тоже допустимый синтаксис
int * iPtr3{}; // тоже допустимый синтаксис (но не делайте так, это похоже на умножение)
 
// При объявлении нескольких переменных в одной строке звездочка должна 
// быть у каждой переменной.
int *iPtr4{}, *iPtr5{}; // объявляем два указателя на переменные типа int (не рекомендуется)

Синтаксически C++ принимает звездочку рядом с типом данных, рядом с именем переменной или даже в середине.

Лучшая практика


При объявлении переменной-указателя ставьте звездочку рядом с типом, чтобы его было легче отличить от косвенного обращения.

Как и обычные переменные, указатели не инициализируются при объявлении. Если они не инициализированы значением, они будут содержать мусор.

Одно замечание по номенклатуре указателей: «указатель X» (где X – какой-либо тип) – это обычно используемое сокращение для «указателя на X». Поэтому, когда мы говорим «указатель int», мы на самом деле имеем в виду «указатель на значение типа int».

Присвоение значения указателю

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

Чтобы получить адрес переменной, мы используем оператор адреса:

int v{ 5 };
int* ptr{ &v }; // инициализируем ptr адресом переменной v

Концептуально вы можете представить приведенный выше фрагмент так:

Рисунок 1 – Значение, хранимое указателем
Рисунок 1 – Значение, хранимое указателем

Отсюда указатели и получили свое название – ptr (от англ. «pointer», «указатель») содержит адрес значения переменной, поэтому мы говорим, что ptr «указывает на» v.

Это также легко увидеть с помощью кода:

#include <iostream>
 
int main()
{
    int v{ 5 };
    int* ptr{ &v }; // инициализируем ptr адресом переменной v
 
    std::cout << &v << '\n';  // выводим адрес переменной v
    std::cout << ptr << '\n'; // выводим адрес, который хранится в ptr
 
    return 0;
}

На машине автора эта программа напечатала:

0012FF7C
0012FF7C

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

int iValue{ 5 };
double dValue{ 7.0 };
 
int* iPtr{ &iValue };    // ok
double* dPtr{ &dValue }; // ok
iPtr = &dValue; // неправильно - указатель типа int не может указывать на адрес переменной типа double
dPtr = &iValue; // неправильно - указатель типа double не может указывать на адрес переменной типа int

Обратите внимание, что следующее также некорректно:

int* ptr{ 5 };

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

C++ также не позволит вам напрямую преобразовать литеральные адреса памяти в указатель:

double* dPtr{ 0x0012FF7C }; // не допустимо

Оператор адреса возвращает указатель

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

Мы можем увидеть это в следующем примере:

#include <iostream>
#include <typeinfo>
 
int main()
{
	int x{ 4 };
	std::cout << typeid(&x).name() << '\n';
 
	return 0;
}

В Visual Studio 2013 этот код напечатал:

int *

gcc вместо этого выводит "pi" («pointer to int», указатель на int).

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

Косвенное обращение через указатели

Когда у нас есть переменная-указатель, указывающая на что-то, другая распространенная вещь, которую мы делаем с ней, – это косвенное обращение через указатель для получения значения того, на что он указывает. Косвенное обращение через указатель вычисляет содержимое адреса, на который он указывает.

int value{ 5 };
std::cout << &value; // выводит адрес value
std::cout << value;  // выводит содержимое value
 
int* ptr{ &value }; // ptr указывает на value
std::cout << ptr;   // выводит адрес, содержащийся в ptr, который равен &value
std::cout << *ptr;  // косвенное обращение через ptr (получаем значение, на которое указывает ptr)

На машине автора этот код напечатал:

0012FF7C
5
0012FF7C
5

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

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

int value1{ 5 };
int value2{ 7 };
 
int* ptr{};
 
ptr = &value1;     // ptr указывает на value1
std::cout << *ptr; // выводит 5
 
ptr = &value2;     // ptr теперь указывает на value2
std::cout << *ptr; // выводит 7

Когда адрес переменной value присваивается указателю ptr, верно следующее:

  • ptr равен &value
  • *ptr обрабатывается так же, как value

Поскольку *ptr обрабатывается так же, как value, вы можете присваивать ему значения, как если бы это была переменная value! Следующая программа напечатает 7:

int value{ 5 };
int* ptr{ &value }; // ptr указывает на value
 
*ptr = 7; // *ptr - это то же, что и value, которому присвоено 7
std::cout << value << '\n'; // выводит 7

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

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

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

Следующая программа иллюстрирует это и, вероятно, выйдет со сбоем, когда вы запустите ее (попробуйте, вы не навредите своей машине):

#include <iostream>

// Мы рассмотрим & позже. Пока не беспокойтесь об этом, мы используем его только для того,
// чтобы заставить компилятор думать, что p имеет значение. 
void foo(int*&p) 
{
    // p - ссылка на указатель. Мы рассмотрим ссылки (и ссылки на указатели) позже в этой главе.
    // Мы используем ее, чтобы заставить компилятор думать, что p мог быть изменен,
    // поэтому он не будет жаловаться на то, что p неинициализирован.
    // Это не то, что вам когда-либо захочется делать намеренно.
}
 
int main()
{
    int* p; // Создаем неинициализированный указатель (указывающий на мусор)
    foo(p); // Обманываем компилятор, заставляя его думать, что мы собираемся
            // присвоить указателю допустимое значение
	    
    std::cout << *p << '\n'; // Косвенное обращение через указатель на мусор
 
    return 0;
}

Размер указателей

Размер указателя зависит от архитектуры, для которой скомпилирован исполняемый файл – 32-битный исполняемый файл использует 32-битные адреса памяти – следовательно, указатель на 32-битной машине занимает 32 бита (4 байта). С 64-битным исполняемым файлом указатель будет 64-битным (8 байтов). Обратите внимание, что это верно независимо от размера объекта, на который он указывает:

char* chPtr{}; // char равен 1 байту
int* iPtr{};   // int обычно равен 4 байтам
 
struct Something
{
    int x{};
    int y{};
    int z{};
};
 
Something* somethingPtr{}; // Something, вероятно, занимает 12 байт
 
std::cout << sizeof(chPtr) << '\n';        // выводит  4
std::cout << sizeof(iPtr) << '\n';         // выводит 4
std::cout << sizeof(somethingPtr) << '\n'; // выводит 4

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

Что хорошего в указателях?

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

Оказывается, указатели полезны во многих разных случаях:

  1. Массивы реализованы с помощью указателей. Указатели могут использоваться для итерации по массиву (как альтернатива индексам массива) (рассматривается в уроке «10.24 – Введение в итераторы»).
  2. В C++ это единственный способ динамического выделения памяти (рассматривается в уроке «10.13 – Динамическое выделение памяти с помощью new и delete»). Это, безусловно, наиболее распространенный вариант использования указателей.
  3. Их можно использовать для передачи функции в качестве параметра другой функции (рассматривается в уроке «11.9 – Указатели на функции»).
  4. Их можно использовать для достижения полиморфизма при работе с наследованием (рассматривается в уроке «18.1 – Указатели и ссылки базового класса на объекты производных классов»).
  5. Их можно использовать, чтобы иметь указатель на одну структуру/класс в другой структуре/классе, чтобы сформировать цепочку. Это полезно в некоторых более сложных структурах данных, таких как связанные списки и деревья.

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

Заключение

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

Небольшой тест

Вопрос 1

Какие значения выводит эта программа? Предположим, что short занимает 2 байта, а машина 32-битная.

short value{ 7 };      // &value = 0012FF60
short otherValue{ 3 }; // &otherValue = 0012FF54
 
short* ptr{ &value };
 
std::cout << &value << '\n';
std::cout << value << '\n';
std::cout << ptr << '\n';
std::cout << *ptr << '\n';
std::cout << '\n';
 
*ptr = 9;
 
std::cout << &value << '\n';
std::cout << value << '\n';
std::cout << ptr << '\n';
std::cout << *ptr  << '\n';
std::cout << '\n';
 
ptr = &otherValue;
 
std::cout << &otherValue << '\n';
std::cout << otherValue << '\n';
std::cout << ptr << '\n';
std::cout << *ptr << '\n';
std::cout << '\n';
 
std::cout << sizeof(ptr) << '\n';
std::cout << sizeof(*ptr) << '\n';

0012FF60
7
0012FF60
7

0012FF60
9
0012FF60
9

0012FF54
3
0012FF54
3

4
2

Краткое объяснение про 4 и 2. 32-битная машина означает, что указатели будут иметь длину 32 бита, но sizeof() всегда выводит размер в байтах. 32 бита – это 4 байта. Таким образом, sizeof(ptr) равен 4. Поскольку ptr является указателем на short, *ptr – это short. Размер short в этом примере составляет 2 байта. Таким образом, sizeof(*ptr) равен 2.


Вопрос 2

Что не так с этим фрагментом кода?

int value{ 45 };
int* ptr{ &value }; // объявляем указатель и инициализируем адресом value
*ptr = &value;      // присвоить ptr адрес value

Последняя строка приведенного выше фрагмента не компилируется.

Разберем эту программу подробнее.

Первая строка содержит определение обычной переменной вместе со значением инициализации. Здесь ничего особенного.

Во второй строке мы определяем новый указатель с именем ptr и сохраняем в нем адрес value. Помните, что в этом контексте звездочка является частью синтаксиса объявления указателя, а не косвенным обращением через указатель. Так что с этой строкой всё в порядке.

В третьей строке звездочка представляет собой косвенное обращение, которое используется для получения значения, на которое указывает указатель. Итак, эта строка говорит: «Получить значение, на которое указывает ptr (целочисленное значение типа int), и перезаписать его адресом value. В этом нет никакого смысла – вы не можете присвоить адрес значению типа int!

Третья строка должна быть:

ptr = &value;

Это правильно присваивает указателю адрес переменной value.

Теги

C++ / CppLearnCppДля начинающихОбучениеОператор адресаОператор косвенного обращения / Оператор разыменованияПрограммированиеУказатель / Pointer (программирование)

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.