1. Переменная как объект в памяти: значение и адрес
Представьте память компьютера как огромную линейку из маленьких ячеек по 1 байту:
flowchart TD
subgraph Row1["Адреса"]
direction LR
R1000["1000"]
R1001["1001"]
R1002["1002"]
R1003["1003"]
R1004["1004"]
R1005["1005"]
end
subgraph Row2["Данные (по 1 байту)"]
direction LR
B1000["[байт]"]
B1001["[байт]"]
B1002["[байт]"]
B1003["[байт]"]
B1004["[байт]"]
B1005["[байт]"]
end
Любая переменная занимает несколько байтов подряд:
- char обычно 1 байт,
- int часто 4 байта,
- double часто 8 байт,
- std::string внутри устроен сложно, но для нас важно одно: это тоже объект в памяти.
У каждого объекта в памяти есть адрес — “координата”, где объект начинается.
Адрес — это НЕ значение
Допустим у вас есть такой код:
int balance = 42;
У balance есть:
- значение: 42
- адрес: что-то вроде 0x7ffeefbff5ac (точное число не важно)
И это две разные сущности. Очень разные.
2. Оператор взятия адреса &
C++ позволяет получить адрес переменной: номер ячейки памяти где она хранится. Для этого используется оператор & и читается он так:
&x — “адрес переменной x”
Возьмём переменную из нашего мини‑приложения “Копилка” (баланс) и посмотрим на неё глазами памяти:
#include <iostream>
int main() {
int balance = 42;
std::cout << "balance = " << balance << '\n';
std::cout << "&balance = " << &balance << '\n';
}
Адрес обычно печатается как 0x... (шестнадцатеричное число). И да: адрес может быть другим при следующем запуске программы. Это нормально: операционная система любит переставлять вещи (в том числе ради безопасности).
Какой тип у &balance?
Если balance имеет тип int, то &balance имеет тип int*. То есть “адрес int” — это значение типа “указатель на int”.
3. Размер объекта: sizeof
Раз тип — это ещё и “сколько памяти нужно”, логично уметь это проверять. Для этого есть оператор sizeof.
#include <iostream>
int main() {
int x = 0;
double y = 0.0;
std::cout << "sizeof(x) = " << sizeof(x) << '\n';
std::cout << "sizeof(y) = " << sizeof(y) << '\n';
}
Чаще всего вы увидите что-то вроде:
sizeof(x) = 4
sizeof(y) = 8
Это не “магия компилятора”, это просто факт: тип задаёт, сколько байт выделяется под объект.
И маленькая любопытная деталь: размер указателя часто одинаковый (например, 8 байт на 64‑битных системах), даже если он “указывает” то на int, то на double. Указатель хранит адрес, а адреса в рамках одной платформы имеют одну “длину”.
#include <iostream>
int main() {
int x = 1;
int* px = &x;
std::cout << "sizeof(int) = " << sizeof(int) << '\n';
std::cout << "sizeof(px) = " << sizeof(px) << '\n';
}
4. Что можно адресовать, а что нельзя
Адрес можно взять у того, что реально существует как отдельный объект.
У переменной адрес получить можно:
int x = 10;
std::cout << &x << '\n';
А вот у “промежуточного результата вычисления” адрес взять нельзя:
int x = 10;
std::cout << &(x + 1) << '\n'; // не компилируется
На текущем уровне достаточно правила: Адрес берём у “настоящей переменной/объекта”, а не у “результата формулы”.
5. Ссылка T&: “второе имя” объекта
Так же с помощью & можно делать переменные-ссылки. Ссылка — это alias. Псевдоним. Второе имя уже существующего объекта.
#include <iostream>
int main() {
int balance = 100;
int& refBalance = balance; // refBalance — второе имя balance
refBalance = refBalance + 50; // меняем через ссылку
std::cout << balance << '\n'; // 150
}
Переменная refBalance указывает на тот же адрес памяти что и balance.
Важно понять суть: мы не создавали копию. Объект один, имён два.
Проверяем, что объект один: адреса совпадают
Чтобы убедиться, что две переменные указывают на одну область памяти, достаточно воспользоваться оператором &, с которым вы уже знакомы. Пример:
#include <iostream>
int main() {
int balance = 100;
int& refBalance = balance;
std::cout << &balance << '\n';
std::cout << &refBalance << '\n'; // тот же адрес
}
Если адрес одинаковый, то и объект один и тот же.
Правила ссылок (коротко, но строго)
Переменную-ссылку нужно инициализировать сразу. То есть так нельзя:
int& r; // ошибка: ссылка не может быть “пустой”
Ссылка не “переназначается” на другой объект. И вот здесь ломается мозг у всех новичков (вы не исключение, расслабьтесь):
#include <iostream>
int main() {
int a = 1;
int b = 2;
int& r = a; // r — это a
r = b; // это НЕ “перепривязать r”
// это “присвоить a значение b”
std::cout << a << '\n'; // 2
}
То есть r = b; — обычное присваивание, потому что r ведёт себя как “то же самое, что a”.
На нашем уровне это полезно запомнить как простую мысль: ссылка всегда ссылается на реальный объект.
Не путайте & в типе и & как оператор
Это два разных смысла одного символа:
- int& ref = balance; — & часть типа (ссылка)
- &balance — операция “взять адрес”
C++ тут как тот друг, который один и тот же мем использует в разных ситуациях: “ну вы же поняли по контексту”. Да, по контексту.
6. Указатель T*: переменная, которая хранит адрес
Указатель — это не ссылка, это отдельная переменная. Её значение — адрес другого объекта.
#include <iostream>
int main() {
int balance = 100;
int* pBalance = &balance; // pBalance хранит адрес balance
std::cout << "pBalance = " << pBalance << '\n'; // адрес
std::cout << "*pBalance = " << *pBalance << '\n'; // значение по адресу
}
Тут важно разделить в голове три сущности:
- balance — объект с числом
- &balance — адрес объекта
- pBalance — объект, который хранит адрес (то есть pBalance == &balance)
Разыменование *p
Оператор * в выражении означает “взять значение по адресу”.
#include <iostream>
int main() {
int balance = 100;
int* pBalance = &balance;
*pBalance = *pBalance + 50; // меняем balance через указатель
std::cout << balance << '\n'; // 150
}
Указатель тоже переменная (у него тоже есть адрес)
Это иногда помогает “приземлить” картину мира:
#include <iostream>
int main() {
int balance = 100;
int* pBalance = &balance;
std::cout << "&balance = " << &balance << '\n';
std::cout << "pBalance = " << pBalance << '\n';
std::cout << "&pBalance = " << &pBalance << '\n';
}
&pBalance — это адрес самого указателя в памяти.
nullptr: указатель “в никуда”
Указатель может иметь специальное значение nullptr — “ни на что не указывает”.
#include <iostream>
int main() {
int* p = nullptr;
if (p != nullptr) {
std::cout << *p << '\n'; // безопасно только если p валиден
} else {
std::cout << "Pointer is null\n";
}
}
На текущем уровне запомните правило безопасности, как правило дорожного движения: Разыменовывать *p можно только если p != nullptr и указывает на живой объект.
Указатель можно переназначать
В отличие от ссылки, указатель можно “перенаправить” на другой объект:
#include <iostream>
int main() {
int a = 1;
int b = 2;
int* p = &a;
std::cout << *p << '\n'; // 1
p = &b; // теперь p указывает на b
std::cout << *p << '\n'; // 2
}
7. Полезные нюансы
Бонус: -> для указателей на объекты
Если у вас указатель на объект, и вы хотите вызвать метод, есть два равных варианта:
- (*p).method() — длинно, но честно
- p->method() — коротко и удобно
#include <iostream>
#include <string>
int main() {
std::string owner = "Alex";
std::string* pOwner = &owner;
std::cout << (*pOwner).size() << '\n';
std::cout << pOwner->size() << '\n'; // то же самое
}
Переменная vs ссылка vs указатель
| Сущность | Что это | Может быть “пустой”? | Можно перенаправить? | Как получить значение |
|---|---|---|---|---|
|
сам объект | — | — | |
|
второе имя объекта |
нет | нет | |
|
отдельный объект, хранит адрес |
да () |
да | |
Если хочется коротко “в одно предложение”:
Ссылка выглядит как переменная и ведёт к одному и тому же объекту, а указатель — это отдельная переменная‑адрес, которую нужно проверять и разыменовывать.
Область видимости и “висячие” адреса: когда адрес становится опасным
Вы уже знаете, что переменная живёт в пределах блока { ... }. Теперь добавим к этому важное следствие: Адрес корректен только пока жив объект, на который он указывает.
Самый простой пример “висячего указателя”:
#include <iostream>
int main() {
int* p = nullptr;
{
int temp = 123;
p = &temp;
std::cout << *p << '\n'; // 123 — тут всё хорошо
}
// temp уже не существует, а p хранит старый адрес.
std::cout << *p << '\n'; // ОПАСНО (не делайте так)
}
Почему это так коварно? Потому что программа иногда “как будто работает”, а иногда падает, а иногда начинает печатать ерунду. Это одна из причин, почему указатели считают “опасными”: они дают много свободы, а свобода требует дисциплины.
Со ссылками похожая история тоже возможна (ссылка тоже может стать “висячей”), но указатели чаще попадают в такие ловушки просто потому, что их можно хранить отдельно и переназначать.
8. Встраиваем тему в наше мини‑приложение “Копилка”: режим инспектора
Мы начали “Копилку” в прошлых лекциях: читаем пополнение и расход, обновляем баланс. Сегодня добавим режим “инспектора”: покажем, что баланс — это не абстрактное число, а объект с адресом, и что его можно менять разными способами.
Баланс и его адрес
#include <iostream>
int main() {
int balance = 0;
std::cout << "balance = " << balance << '\n';
std::cout << "&balance = " << &balance << '\n';
}
Ссылка на баланс: меняем “как будто напрямую”
#include <iostream>
int main() {
int balance = 0;
int& refBalance = balance;
refBalance = refBalance + 10; // пополнили через ссылку
std::cout << balance << '\n'; // 10
}
Указатель на баланс: меняем через *
#include <iostream>
int main() {
int balance = 0;
int* pBalance = &balance;
*pBalance = *pBalance + 25; // пополнили через указатель
std::cout << balance << '\n'; // 25
}
Мини-сценарий: пополнение, расход и “один и тот же объект”
Здесь мы уже делаем маленький “живой” кусок: вводим два числа, обновляем баланс, но делаем это через ссылку и указатель, чтобы мозг привык к идее “разные способы доступа к одному объекту”.
#include <iostream>
int main() {
int balance = 0;
int& refBalance = balance;
int* pBalance = &balance;
int deposit = 0, spend = 0;
std::cin >> deposit >> spend;
refBalance = refBalance + deposit; // через ссылку
*pBalance = *pBalance - spend; // через указатель
std::cout << "balance = " << balance << '\n';
}
Если вы сейчас подумали “это странно, зачем так делать, можно же просто balance = balance + deposit;” — отлично! Значит вы понимаете, что это учебный эксперимент, а не стиль для реального проекта.
Смысл упражнения в другом: вы учитесь видеть, что refBalance и *pBalance работают с тем же самым объектом.
9. Типичные ошибки
Ошибка №1: неинициализированный указатель.
Если написать int* p; и не дать ему значение, то внутри будет “мусорный адрес”. Иногда программа сразу упадёт при *p, иногда начнёт портить память, а иногда (самый опасный вариант) “почти работает”. На уровне привычки лечится просто: любой указатель либо сразу получает валидный адрес, либо nullptr.
Ошибка №2: разыменование nullptr.
nullptr — это не “нулевой объект”, это “вообще нет объекта”. Разыменование *p при p == nullptr — плохая идея, которая обычно заканчивается аварийно. Поэтому проверка if (p != nullptr) перед разыменованием — не паранойя, а базовая гигиена.
Ошибка №3: путаница * и & в объявлении и в выражении.
int* p — звёздочка часть типа (“указатель”).
*p — звёздочка действие (“взять значение по адресу”).
Точно так же int& r — амперсанд часть типа (“ссылка”), а &x — операция “взять адрес”. Один и тот же символ, два смысла — классика C++: язык экономит символы, а вы потом тратите нервные клетки. Со временем это начинает читаться автоматически.
Ошибка №4: ожидание, что ссылку можно “перепривязать”.
Если у вас int& r = a;, то r навсегда про a. Строка r = b; не меняет “куда смотрит ссылка”, она меняет значение a. Если вам нужна именно возможность переключаться между объектами — это уже сценарий указателя.
Ошибка №5: висячий указатель после выхода из блока.
Если вы взяли адрес переменной, которая живёт внутри { ... }, то после закрывающей } объект уничтожен, а адрес становится “висячим”. Это не “редкая теория”, это реальная причина странных багов. Хорошая привычка: не хранить адреса на “внутренние” переменные дольше, чем живёт их блок, и особенно не использовать такие адреса после выхода из блока.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ