1. Базовая идея: p — это «где», *p — это «что»
Когда вы впервые видите указатели, возникает логичный вопрос: «Зачем мне хранить адрес, если я хочу работать с данными?» И это правильный вопрос. Адрес сам по себе обычно не интересен — он нужен как маршрут к данным. Разыменование — это как открыть дверь по адресу в навигаторе: у вас есть координаты (p), и вы хотите попасть внутрь квартиры (*p).
Главная идея сегодняшней лекции очень приземлённая:
p — это “где”, *p — это “что лежит по этому адресу”.
А когда объект — это struct, мы хотим не просто «войти в квартиру», а ещё и «открыть конкретный шкафчик» — тут появляется оператор ->.
p и *p: адрес и объект
Давайте начнём максимально спокойно. У нас есть переменная x. Она хранит число. У неё есть адрес. Мы можем взять этот адрес, положить в указатель, а потом через указатель снова добраться до x.
Ниже — табличка, которую полезно держать в голове (она спасает от половины типичных ошибок с указателями).
| Выражение | Пример | «Что это?» | Тип | Что означает по смыслу |
|---|---|---|---|---|
| объект (значение) | |
данные | |
само число |
| адрес объекта | |
координата в памяти | |
где лежит x |
| указатель (переменная) | |
данные-координата | |
адрес, сохранённый в p |
| разыменование | |
данные по адресу | |
объект, на который указывает p |
Теперь маленький пример: просто напечатаем значение, адрес и значение «через адрес».
#include <iostream>
int main() {
int x = 42;
int* p = &x;
std::cout << "x = " << x << '\n'; // x = 42
std::cout << "&x = " << &x << '\n'; // &x = 0x.... (адрес будет другим)
std::cout << "*p = " << *p << '\n'; // *p = 42
}
Обратите внимание на важный психологический момент: p — это НЕ «особый доступ к x». p — это просто переменная, в которой лежит число-адрес. Магия начинается только в момент *p.
2. Чтение и запись через *p
*p для чтения
Когда мы говорим «разыменовать указатель», мы делаем операцию *p и получаем доступ к объекту, который лежит по адресу p. На практике чаще всего первое действие — просто прочитать значение, чтобы убедиться, что мы смотрим туда, куда хотели (это очень человеческий дебаг).
Пример максимально простой: меняем x, и видим, что чтение через *p отражает текущее состояние x.
#include <iostream>
int main() {
int x = 10;
int* p = &x;
std::cout << *p << '\n'; // 10
x = 25;
std::cout << *p << '\n'; // 25
}
Здесь полезно осознать: *p не «сохраняет копию». *p каждый раз идёт по адресу и смотрит на живой объект.
*p для записи
Теперь самое вкусное: *p можно использовать не только справа (чтение), но и слева (запись). Это и есть «управление объектом по адресу».
Пусть у нас есть x = 10, и указатель p на x. Если мы напишем *p = 99;, мы изменим сам x, потому что *p — это и есть x, только «достали его по адресу».
#include <iostream>
int main() {
int x = 10;
int* p = &x;
*p = 99; // меняем x через указатель
std::cout << x << '\n'; // 99
}
Это место, где многие новички начинают подозревать, что C++ — это не язык, а квест «угадай, что имелось в виду». Но здесь всё честно: *p — это объект, значит его можно менять.
Частая путаница: p = &x и *p = x
На этом шаге полезно специально проговорить два похожих выражения, которые означают совершенно разное. Потому что именно тут рождается много багов, которые потом выглядят как «оно само сломалось, честно».
Сравним:
#include <iostream>
int main() {
int x = 5;
int y = 100;
int* p = &x; // p хранит адрес x
p = &y; // теперь p хранит адрес y (мы ПЕРЕНАПРАВИЛИ указатель)
*p = 7; // теперь меняем y, потому что p указывает на y
std::cout << x << '\n'; // 5
std::cout << y << '\n'; // 7
}
Смысл такой:
p = &y; меняет адрес внутри указателя (куда указываем).
*p = 7; меняет данные по адресу (что лежит там, куда указываем).
Если коротко: одна строка меняет «стрелочку», вторая — «мишень».
3. nullptr и проверка перед разыменованием
Сейчас будет правило, которое звучит скучно, но экономит часы жизни:
Если указатель может быть nullptr, то разыменовывать его можно только после проверки.
Проверка обычно делается через if (p != nullptr).
#include <iostream>
int main() {
int* p = nullptr;
if (p != nullptr) {
std::cout << *p << '\n';
} else {
std::cout << "p is null\n"; // p is null
}
}
Почему так строго? Потому что nullptr — это не адрес объекта. Это «пустое состояние». Попытка сделать *nullptr — это как попытка открыть дверь, которой не существует: не «ошибка доступа по расписанию», а лотерея из серии «а что сегодня сломаем».
Отдельный стильный момент: в современном C++ принято использовать именно nullptr, а не 0 или NULL, и это не просто прихоть — такой стиль закреплялся и вычищался в стандартных текстах много лет.
4. Указатель на struct: оператор ->
Почему появляется ->, если уже есть *p
Когда мы работаем со структурой, у нас есть поля. Для обычного объекта мы обращаемся к полю через точку: obj.field. Но если у нас указатель p, то p — это не объект, а адрес. Значит точка не подходит: у адреса нет поля age (у него вообще нет полей, он «координата»).
Чтобы обратиться к полю структуры по указателю, используется оператор ->:
- p->field читается как «возьми объект по адресу p и возьми его поле field».
Сделаем минимальный пример со структурой User.
#include <iostream>
#include <string>
struct User {
std::string name;
int age{};
};
int main() {
User u{"Bob", 30};
User* p = &u;
std::cout << p->name << '\n'; // Bob
p->age += 1;
std::cout << u.age << '\n'; // 31
}
Это очень важная демонстрация: изменение через p->age меняет исходный объект u, потому что p указывает именно на него.
p->field и (*p).field
Теперь момент, который часто выглядит как «заклинание на латыни», но на деле вполне логичен.
p->field — это сокращение для (*p).field.
То есть сначала мы разыменовываем p, получаем объект, а потом берём у него поле через точку.
Однако есть нюанс: у операторов есть приоритет. Оператор . (точка) имеет высокий приоритет, поэтому выражение *p.field означает не то, что вы хотите (и обычно вообще не компилируется или компилируется не туда).
Правильно так:
(*p).age — сначала разыменовали p, потом взяли age
p->age — то же самое, но читается легче
Давайте прямо покажем эквивалентность на коде.
#include <iostream>
#include <string>
struct User {
std::string name;
int age{};
};
int main() {
User u{"Ann", 20};
User* p = &u;
std::cout << p->age << '\n'; // 20
std::cout << (*p).age << '\n'; // 20
}
Почему -> так любят? Потому что (*p).age выглядит как «я случайно задел клавиатуру локтем, и оно сработало». А p->age читается почти как английское “pointer to age”.
5. Мини-схема: что происходит при *p и p->field
Сейчас будет схема, чтобы мозг мог отдохнуть от текста и переключиться на «картинки». Представим, что p указывает на u.
flowchart LR
P[p: User*] -->|содержит адрес| U[User u]
U --> NAME[name]
U --> AGE[age]
P2["*p"] -->|это| U
P3["p->age"] -->|это| AGE
Именно так полезно думать: *p — это «сам объект», p->age — «конкретное поле объекта».
6. Пример: «Заметки» и редактирование через указатель
Чтобы не было ощущения, что указатели — это «ну вот в учебнике так написано», давайте добавим очень маленький кусочек логики в наше условное консольное приложение “Заметки” (или “Список задач”). Мы не будем делать ничего большого: просто покажем, как указатель может означать «текущий выбранный объект», и как через -> можно менять поля.
Пусть у нас есть модель:
#include <string>
struct Note {
int id{};
std::string title;
bool pinned{};
};
Теперь представим, что мы «выбрали» конкретную заметку n и хотим поменять pinned через указатель.
#include <iostream>
#include <string>
struct Note {
int id{};
std::string title;
bool pinned{};
};
int main() {
Note n{1, "Buy milk", false};
Note* selected = &n;
selected->pinned = true;
std::cout << n.pinned << '\n'; // 1 (true печатается как 1)
}
Да, вывод true как 1 — это нормально. Мы позже привыкнем к std::boolalpha, но сейчас нам важно другое: поле реально поменялось.
Теперь чуть интереснее: покажем, что можно менять и строку, и булево поле, и всё это остаётся в исходном объекте.
#include <iostream>
#include <string>
struct Note {
int id{};
std::string title;
bool pinned{};
};
int main() {
Note n{2, "Read C++ book", false};
Note* selected = &n;
selected->title += " (chapter pointers)";
selected->pinned = !selected->pinned;
std::cout << n.title << '\n'; // Read C++ book (chapter pointers)
std::cout << n.pinned << '\n'; // 1
}
Смысл этого фрагмента для нашего приложения такой: мы можем хранить “текущий выбранный объект” как указатель и работать с ним единым способом, независимо от того, где он создан, пока он жив.
Сегодня мы не углубляемся в то, как именно выбирать заметку из контейнера и как гарантировать, что адрес не «протухнет» — это отдельная тема про безопасность и время жизни, и до неё мы ещё дойдём. Сейчас нам достаточно отработать «механику рук»: *p и p->field.
7. Типичные ошибки при разыменовании и ->
Ошибка №1: разыменование nullptr.
Самый частый сценарий: указатель объявили, поставили nullptr «на всякий случай», а потом в каком-то месте забыли, что он может быть пустым. В итоге появляется *p или p->field без проверки. Лечится дисциплиной: если указатель по контракту может быть пустым, перед каждым разыменованием должен быть if с проверкой p != nullptr или другая явная гарантия.
Ошибка №2: путаница “меняю указатель” и “меняю объект”.
p = &x; — вы меняете адрес в указателе. *p = 10; — вы меняете данные по адресу. Если перепутать, программа начинает вести себя так, будто у неё есть настроение и фаза луны в зависимостях. Спасает привычка проговаривать: «я сейчас двигаю стрелку или меняю цель?».
Ошибка №3: попытка писать p.field у указателя.
Это обычно происходит по инерции: “я же всегда писал через точку”. Но точка работает только с объектом, а не с адресом. Для указателя либо p->field, либо (*p).field. Если вы видите точку рядом с переменной-указателем — это почти всегда баг.
Ошибка №4: забытые скобки в (*p).field.
Запись *p.field почти никогда не означает то, что хотелось автору. Правильно: (*p).field. На практике проще сразу писать p->field, и проблема исчезает вместе с лишними скобками и лишними седыми волосами.
Ошибка №5: вера в то, что “если указатель не null — значит всё безопасно”.
Проверка p != nullptr защищает только от пустого состояния. Но она не доказывает, что объект по адресу жив, не уничтожен и вообще существует. Сегодня мы это лишь фиксируем как осторожность, а подробно разберём уже в теме про безопасность указателей и время жизни объектов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ