Бесплатный курс по C++. Зарегистрируйтесь для отслеживания прогресса →

C++: Указатели

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

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

Указатель — это переменная, которая в качестве значения хранит адрес памяти.

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

int *i_ptr {};    // указатель на значение типа int
double *d_ptr {}; // указатель на значение типа double

int* i_ptr2 {};  // тоже допустимый синтаксис
int * iPtr3{}; // тоже допустимый синтаксис (но не делайте так, это похоже на умножение)

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

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

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

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

Хорошей практикой считается инициализировать указатель значением.

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

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

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

#include <iostream>

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

  std::cout << &num << '\n';  // выводим адрес переменной num
  std::cout << ptr << '\n'; // выводим адрес, который хранится в ptr

  return 0;
}

Эта программа создает следующий вывод:

  0x7ffc5d336fc8
  0x7ffc5d336fc8

ptr содержит адрес значения переменной, поэтому мы говорим, что ptr «указывает на» num.

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

int i_value { 5 };
double d_value { 7.0 };

int* i_ptr { &iValue };    // ok
double* d_ptr { &dValue }; // ok
i_ptr = &d_value; // ошибка

Тип double не может указывать на адрес переменной типа int. Следующее также некорректно:

int* ptr { 5 };

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

Вопрос на засыпку: Можно ли инициализировать указатель, явно указав адрес ячейки памяти?

double* d_ptr{ 0x0012FF7C };

Ответ - нет, компиляция этого кода завершится с ошибкой! Хотя казалось бы, почему, ведь оператор адреса &, так же возвращает адрес? Тут есть отличие - оператор & возвращает тоже указатель.

Возвращение указателя оператором адреса

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

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

#include <iostream>
#include <typeinfo>

int main() {
  int num { 4 };
  std::cout << typeid(&int).name() << std::endl;

  return 0;
}

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


int *

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

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

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

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

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

int value { 5 };
std::cout << &value << std::endl; // выводит адрес value
std::cout << value << std::endl;  // выводит содержимое value

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

Эта программа создает следующий вывод:

  0x7ffcc0b6824c
  5
  0x7ffcc0b6824c
  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 << std::endl; // выводит 7

Обратите внимание, через указатель мы можем работать с переменной value - получить значение, и даже изменить его.

Такой мощный механизм имеет свои минусы.

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

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

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

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

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

#include <iostream>

// Мы рассмотрим & позже. Пока не беспокойтесь об этом. Мы используем его только для того,
// чтобы заставить компилятор думать, что p имеет значение. 
void foo(int*&p) {
  // p — ссылка на указатель. Мы рассмотрим ссылки (и ссылки на указатели) позже в этой главе.
  // Мы используем ее, чтобы заставить компилятор думать, что p мог быть изменен,
  // поэтому он не будет жаловаться на то, что p неинициализирован.
}

int main() {
  int* p; // Создаем неинициализированный указатель (указывающий на мусор)
  foo(p); // Обманываем компилятор, заставляя его думать, что мы собираемся присвоить указателю допустимое значение

  std::cout << *p << std::endl; // Косвенное обращение через указатель на мусор

  return 0;
}

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

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

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

char* ch_ptr {}; // char равен 1 байту
int* i_ptr {}; // int обычно равен 4 байтам

std::cout << sizeof(ch_ptr) << std::endl; // выводит  4
std::cout << sizeof(i_ptr) << std::endl; // выводит 4

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

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

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

## Выводы

В этом уроке мы познакомились с указателями, узнали как их объявлять, как присваивать им значения и как безопасно работать с ними.

Задание

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

Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

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

Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.

Мой код отличается от решения учителя 🤔

Это нормально 🙆, в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.

В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.

Прочитал урок — ничего не понятно 🙄

Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.

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

Полезное


Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе