1. Погружаемся в T*
Когда вы впервые видите &x, кажется, что вопрос закрыт: «адрес получить можем, всё, победа». Но в реальных программах адрес редко нужен “на секунду”. Чаще хочется сохранить адрес, передать его дальше, сравнить, положить в переменную, сказать “объект есть/объекта нет”, или держать «ссылку на выбранный объект» как состояние. Для этого нам и нужен отдельный тип данных: указатель.
Можно думать об указателе как о записке с координатами: сам объект — это “квартира”, а указатель — “адрес на бумажке”. Бумажка может лежать у вас в кармане, вы можете дать её другу (передать в функцию), можете стереть адрес и написать новый. Но бумажка не является квартирой — это ключевой момент сегодняшней лекции.
Что такое T* по смыслу: «значение-адрес»
Указатель — это переменная, которая хранит адрес. Не значение объекта, не копию объекта, не «маленький объект внутри», а именно адрес, по которому объект находится.
- int x = 10; — переменная x хранит число 10
- int* p = &x; — переменная p хранит адрес x
Очень важно проговаривать это словами, когда читаете код. Не “p равно x”, а “p указывает на x” или “в p лежит адрес x”.
Небольшая схема (грубая, зато честная):
Stack (условно)
+-------------------+
| x: 10 | <-- объект (значение)
+-------------------+
| p: 0x7ffd... | <-- указатель (адрес x)
+-------------------+
Адреса вида 0x7ffd... — это просто формат вывода (обычно шестнадцатеричный). И да: адреса будут разными при разных запусках программы, это нормально. Адрес — не “ID пользователя в вашей базе данных”, а техническая координата на время жизни объекта.
2. Синтаксис и дисциплина инициализации
Как объявить указатель T*
Синтаксис выглядит так:
int* p = nullptr;
Читается: “p — указатель на int”.
И тут новички обычно задают два отличных вопроса:
Первый: почему звёздочка *? Исторически это связано с операцией разыменования (мы её подробно разберём в следующей лекции). Пока воспринимайте * в объявлении как “значок указателя”.
Второй: к чему относится * — к типу или к имени? В C++ это часть объявления, и запись int* p; и int *p; эквивалентны. Лично я советую стиль, который легче читать глазами:
- либо int* p (звёздочка рядом с типом: “указатель-на-int”),
- либо int *p (звёздочка рядом с именем: “p — указатель”).
Главное — не мешайте стили в одном проекте, потому что мозг начнёт тратить силы на ерунду вместо логики.
Главное правило дисциплины: указатель всегда должен быть инициализирован
Самая опасная строка в начале пути указателей — вот такая:
int* p; // плохо: p не инициализирован
Неинициализированный указатель содержит мусорный адрес. Он может выглядеть как “нормальное число”, он может даже иногда “попасть” в какую-то память, но это уже территория UB и лотереи “повезёт/не повезёт”.
Правильная привычка для старта: указатель всегда в одном из двух состояний:
1) либо он равен nullptr (то есть “никуда не указывает”),
2) либо хранит адрес реального объекта, который сейчас существует.
Поэтому стартовая инициализация чаще всего такая:
int* p = nullptr;
Это как поставить переменную в состояние “пока никого не выбрали”. И это абсолютно нормально.
nullptr: корректное «пустое состояние» указателя
Слово nullptr можно перевести как “нулевой указатель”. Но на уровне смысла полезнее думать так: nullptr означает «указатель ни на что не указывает».
Это не ошибка. Это нормальная ситуация, как пустая строка или пустой vector: состояние “пока нет значения”.
Мини-пример:
#include <iostream>
int main() {
int* p = nullptr;
std::cout << std::boolalpha;
std::cout << (p == nullptr) << '\n'; // true
}
Сравнение с nullptr — это базовая проверка “объект есть или нет”.
И ещё одно важное: в современном C++ мы стараемся использовать именно nullptr, а не 0 и не NULL. Потому что nullptr — это отдельное специальное значение для указателей, и компилятор с ним работает более предсказуемо.
3. Присваивание, копирование и сравнение указателей
Присваивание адреса: p = &x
Теперь соберём всё в одну картину: есть объект, и есть указатель, который хранит его адрес.
#include <iostream>
int main() {
int x = 10;
int* p = nullptr;
p = &x; // теперь p хранит адрес x
std::cout << "x = " << x << '\n'; // x = 10
std::cout << "&x = " << &x << '\n'; // &x = 0x...
std::cout << "p = " << p << '\n'; // p = 0x... (тот же адрес)
}
В этом примере полезно увидеть, что p и &x печатаются одинаково (как адрес), потому что внутри p и лежит &x.
Обратите внимание: мы пока не трогаем “что лежит по адресу”. Мы сегодня тренируем именно идею “указатель хранит адрес” и “может быть пустым”.
Копирование указателя — это копирование адреса
Один из самых частых когнитивных багов: студент видит p2 = p1 и подсознательно думает “скопировали объект”. Нет. Мы скопировали бумажку с адресом. Теперь две бумажки указывают на одну и ту же квартиру.
#include <iostream>
int main() {
int x = 42;
int* p1 = &x;
int* p2 = p1; // копия адреса
std::cout << std::boolalpha;
std::cout << (p1 == p2) << '\n'; // true
}
Смысл этого равенства: “они указывают на один и тот же объект”.
Это свойство одновременно очень мощное и очень опасное. Мощное — потому что можно делиться доступом к одному объекту. Опасное — потому что можно случайно получить несколько “точек влияния” на один объект, забыть об этом и потом долго удивляться, почему что-то меняется “само”. Но это уже ближе к следующей лекции, где мы научимся читать/писать по адресу.
Указатели можно сравнивать: с nullptr и друг с другом
Сравнения — это ещё одна базовая операция для указателей. В рамках сегодняшней лекции нам нужны два вида сравнений.
Первый вид — сравнение с nullptr. Это проверка “есть объект или нет”.
#include <iostream>
int main() {
int* p = nullptr;
if (p == nullptr) {
std::cout << "p is empty\n"; // p is empty
}
}
Второй вид — сравнение указателей между собой. Это проверка “один и тот же объект или разные”.
#include <iostream>
int main() {
int a = 1;
int b = 2;
int* pa = &a;
int* pb = &b;
std::cout << std::boolalpha;
std::cout << (pa == pb) << '\n'; // false
std::cout << (pa == &a) << '\n'; // true
}
Мы не обсуждаем здесь “кто больше” (<, >). С указателями такие сравнения существуют, но для новичка это почти всегда лишняя территория боли и недопонимания. Сейчас нам важнее чёткая логика: “пусто / не пусто” и “тот же объект / другой объект”.
4. Типизация указателей: почему int* и double* не смешиваются
В C++ адрес не “просто адрес”. Он всегда несёт смысл типа: адрес int — это int*, адрес double — это double*.
Почему это важно? Потому что тип влияет на то, как язык будет потом интерпретировать доступ по адресу (разыменование). Если тип неправильный — вы просите компилятор сделать потенциально бессмысленную операцию. Поэтому C++ не даёт просто так присваивать указатели разных типов друг другу.
Пример (это должно не компилироваться — и это хорошо):
int main() {
int x = 1;
int* pi = &x;
// double* pd = pi; // ошибка компиляции: разные типы указателей
}
И вот тут у новичка может возникнуть “гениальная” идея: “а я приведу тип!”. В большинстве учебных и прикладных сценариев это плохая идея. Если вы чувствуете желание сделать такое приведение, это часто сигнал: “модель данных спроектирована криво, остановись и подумай”. Приведения указателей — это не тема сегодняшней лекции, и мы сознательно туда не лезем.
5. Учебный сценарий: «выбранный пользователь» как указатель
Чтобы указатели не выглядели как магия ради магии, давайте привяжем их к простой задаче из реального кода: хранить “текущего выбранного пользователя” как состояние.
Представим, что у нас есть struct User (мы умеем struct ещё с прошлых дней), и в main мы иногда выбираем пользователя, а иногда “сбрасываем выбор”. Для этого удобно завести переменную “может быть пользователь, а может не быть”.
Именно здесь nullptr выглядит максимально естественно.
#include <iostream>
#include <string>
struct User {
std::string name;
int age{};
};
int main() {
User alice{"Alice", 20};
User bob{"Bob", 30};
User* selected = nullptr; // пока никто не выбран
selected = &alice; // выбрали Alice
std::cout << (selected == &alice) << '\n'; // 1 (true)
selected = nullptr; // сбросили выбор
std::cout << (selected == nullptr) << '\n'; // 1 (true)
selected = &bob; // выбрали Bob
std::cout << (selected == &bob) << '\n'; // 1 (true)
}
Обратите внимание: мы пока не читаем поля через указатель. Мы просто используем указатель как “маркер выбора” и учимся держать его в корректных состояниях. В следующей лекции мы научимся делать следующий шаг: читать/изменять объект по адресу.
6. Что можно делать с T* уже сейчас
Чтобы структурировать действия, полезно держать в голове простую таблицу. Она не заменяет понимание, но помогает не паниковать, когда видите указатели в коде.
| Операция | Пример | Смысл |
|---|---|---|
| Объявить указатель | |
“p хранит адрес int, пока пусто” |
| Взять адрес | |
получить адрес объекта x |
| Присвоить адрес | |
теперь p указывает на x |
| Скопировать указатель | |
копируем адрес, не объект |
| Проверить на пустоту | |
есть объект или нет |
| Сравнить “тот же объект?” | |
указывают ли на один объект |
Если вы уверенно делаете эти вещи и понимаете смысл, то вы уже на хорошем пути. Дальше будет “доступ по адресу”, и там начнётся самое интересное.
7. Подготовка к следующей лекции: разыменование
Обычно мозг просит: “ну ладно, адрес, а как получить значение?”. Ответ: через разыменование *p. Но разыменование — это отдельная тема (следующая лекция), потому что там появляется главное правило безопасности: разыменовывать можно только валидный указатель.
Чтобы аккуратно показать “куда мы идём”, вот пример, где разыменование намеренно закомментировано:
#include <iostream>
int main() {
int x = 5;
int* p = &x;
std::cout << p << '\n'; // 0x... (адрес)
// std::cout << *p << '\n'; // 5 (разыменование — следующая лекция)
}
Пока достаточно запомнить: p — это адрес, а *p — это уже “то, что лежит по адресу”. И между ними стоит целая пропасть ответственности.
8. Типичные ошибки при работе с T* и nullptr
Ошибка №1: неинициализированный указатель (int* p;).
Это одна из самых опасных вещей, которые можно сделать новичку, потому что ошибка не обязана проявиться сразу. Такой p хранит “мусорный адрес”, и любая попытка использовать его позже может привести к UB. Правильная привычка — инициализировать сразу: int* p = nullptr; или int* p = &x;.
Ошибка №2: использование 0 или NULL вместо nullptr.
Исторически в C и старом C++ часто писали 0 или NULL. В современном C++ это хуже читается и может приводить к неоднозначностям при перегрузках. Если вы хотите обозначить “пустой указатель”, пишите nullptr и экономьте себе нервы на будущих темах.
Ошибка №3: ожидание, что p2 = p1 копирует объект.
Присваивание указателей копирует только адрес. В итоге две переменные начинают указывать на один и тот же объект, и это может неожиданно “связать” две части программы. Полезная привычка — мысленно проговаривать: “я копирую координаты, а не данные”.
Ошибка №4: путаница типа указателя (int* против double*).
Адрес в C++ типизирован. Если вы пытаетесь присвоить int* в double*, компилятор ругается не из вредности, а потому что иначе вы легко получите бессмысленную интерпретацию памяти. В начале пути стоит вообще избегать приведений указателей и относиться к ошибке компилятора как к подсказке: “ты пытаешься смешать разные сущности”.
Ошибка №5: “проверил на nullptr один раз и успокоился навсегда”.
Проверка p != nullptr говорит только “указатель не пуст”. Она ещё не гарантирует, что адрес всегда будет валиден в любой момент времени. Тема “валидность адреса во времени” (lifetime) и “висячие указатели” — отдельный разговор, и он будет дальше по плану дня. Сейчас важно хотя бы не подменять в голове понятия: nullptr — это про пустоту, а корректность адреса — это про жизнь объекта.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ