1. Почему const с указателями так путает
Если у вас уже было ощущение, что const иногда выглядит как «прилипло куда-то слева, а смысл — справа», то это нормально. Указатели добавляют второй слой сложности: указатель — это тоже значение, которое можно менять. Поэтому внезапно появляется два разных вопроса: «можно ли менять объект?» и «можно ли переназначать сам адрес?».
Представьте указатель как бумажку с адресом. На бумажке написано: «ул. Шелдона, дом Купера». Вы можете либо поменять адрес на бумажке (переписать, куда она ведёт), либо пойти по адресу и переставить мебель в квартире (поменять объект). const может запретить одно, другое или сразу оба действия.
Две точки приложения: *p и p
Когда мы пишем int x = 10;, x — переменная-значение. Когда мы пишем int* p = &x;, p тоже переменная-значение, только хранит не число 10, а адрес.
Мини-модель, которую полезно держать в голове:
- *p — это «то, что лежит по адресу».
- p — это «сам адрес» (значение указателя).
И дальше — всегда задаём два вопроса.
Вопрос A. Можно ли менять объект через *p?
Вопрос B. Можно ли менять сам указатель (делать p = &other)?
Если держать эти два вопроса в голове, то весь «зоопарк» const T*, T* const, const T* const превращается в понятную схему, а не в заклинание.
2. Три базовые формы: const T*, T* const, const T* const
const T* (или T const*): указатель на константу
Самый частый вариант, который вы будете видеть в параметрах функций, когда функция обещает «я только посмотрю».
Запись const int* p читается так: «p указывает на const int». То есть через p нельзя менять число. Но сам p — обычная переменная, значит её можно переназначать на другой адрес.
#include <iostream>
int main() {
int a = 1;
int b = 2;
const int* p = &a; // указатель на const int
// *p = 10; // нельзя: объект "только чтение"
p = &b; // можно: сам указатель НЕ const
std::cout << *p << '\n'; // 2
}
Важно: a при этом не обязан быть константным. Он обычный int. Просто через p вы добровольно «снимаете права на запись». Это очень полезная идея: не просить права на изменение, если они не нужны.
T* const: константный указатель
Теперь переворачиваем ситуацию: иногда нужно, чтобы указатель всегда смотрел в одно место, но при этом объект по адресу можно было менять.
Тогда мы делаем p константным, а объект — нет: int* const p.
Ключевая деталь: const стоит после *, то есть относится к самому указателю (к переменной с адресом).
#include <iostream>
int main() {
int a = 1;
int b = 2;
int* const p = &a; // const-указатель на int
*p = 10; // можно: объект НЕ const
// p = &b; // нельзя: p нельзя переназначить
std::cout << a << '\n'; // 10
}
Этот вариант часто полезен внутри реализации (внутри функции/метода), чтобы защититься от случайного переназначения адреса.
const T* const: нельзя менять ни объект, ни адрес
Самый строгий вариант: нельзя менять ни объект по адресу, ни сам адрес. То есть «указатель прибит, и доступ только для чтения».
#include <iostream>
int main() {
int a = 5;
int b = 7;
const int* const p = &a;
// *p = 1; // нельзя: объект const
// p = &b; // нельзя: p const
std::cout << *p << '\n'; // 5
}
С практической точки зрения это особенно удобно в сложных местах кода, где вы хотите гарантировать, что переменная p не «убежит» на другой адрес, и через неё вы точно не испортите данные.
3. Таблица: что можно, а что нельзя
Таблица, которую удобно мысленно доставать каждый раз, когда вы видите * const:
| Запись типа | Как читается по смыслу | Можно менять *p | Можно делать p = ... |
|---|---|---|---|
|
указатель на |
да | да |
|
указатель на |
нет | да |
|
‑указатель на |
да | нет |
|
‑указатель на |
нет | нет |
4. Как читать объявления без паники
Чтобы мозг перестал воспринимать const как хаос, полезно выработать механическую привычку чтения: сначала ищем имя переменной, потом смотрим на символы вокруг него.
Правило для новичка (очень практичное):
- const слева от базового типа делает константным объект (то, что «по адресу»).
- const справа от * делает константным сам указатель (то есть переменную-адрес).
Например, в объявлении:
const int* p;
const относится к int, значит p указывает на const int (нельзя менять *p).
А тут:
int* const p;
const стоит вплотную к имени p, значит нельзя переназначать сам p.
Да, вы можете встретить «чтение справа налево» у опытных людей, но на вашем этапе достаточно привычки: ответьте на два вопроса про *p и p — и вы всё поймёте.
5. «Сужение прав»: почему T* можно превратить в const T*, а обратно — нельзя
В C++ разрешено неявно преобразовать «обычный указатель» в «указатель на const»:
- было: int*
- стало: const int*
Почему? Потому что это безопасно: вы уменьшили права. Было можно менять, стало нельзя.
А вот обратно (из const int* в int*) компилятор обычно запрещает: это было бы «увеличение прав». Ведь если у вас есть const int* p, вы не должны внезапно получить возможность сделать *p = 123.
#include <iostream>
int main() {
int x = 10;
int* p = &x;
const int* cp = p; // ok: сужаем права (теперь "только чтение")
std::cout << *cp << '\n'; // 10
// int* p2 = cp; // нельзя: расширяем права обратно
}
Это одна из причин, почему const-корректность действительно помогает: вы можете передать указатель в функцию, которая читает, и компилятор гарантирует, что функция не «впишет» вам туда что-то лишнее.
6. Пример: задачи и контракты функций
Чтобы это не осталось теорией, продолжим линию учебного консольного приложения: представим, что у нас есть список задач, и мы хотим иногда задачу печатать, а иногда помечать выполненной.
Модель данных — максимально простая:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Читающая функция и изменяющая функция
Сделаем две функции: первая печатает задачу и обещает не менять её. Вторая — меняет задачу (ставит done = true). И вот здесь const в указателях начинает «говорить» за вас.
#include <iostream>
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
void PrintTask(const Task* t) {
if (t == nullptr) return;
std::cout << t->id << ": " << t->title << '\n';
}
void MarkDone(Task* t) {
if (t == nullptr) return;
t->done = true;
}
Контракт читается прямо из сигнатуры:
- PrintTask(const Task* t) говорит: «я могу получить nullptr, и я не буду менять задачу».
- MarkDone(Task* t) говорит: «я могу получить nullptr, но если задача есть — я буду её менять».
И маленький main, чтобы увидеть, что всё стыкуется:
#include <iostream>
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
void PrintTask(const Task* t) {
if (!t) return;
std::cout << t->id << ": " << t->title << " done=" << t->done << '\n';
}
void MarkDone(Task* t) {
if (!t) return;
t->done = true;
}
int main() {
Task t{1, "Read about const pointers", false};
PrintTask(&t); // 1: Read about const pointers done=0
MarkDone(&t);
PrintTask(&t); // 1: Read about const pointers done=1
}
Здесь важно, что &t типа Task* спокойно передаётся в PrintTask(const Task*), потому что это «сужение прав»: функция обещает не менять.
Где пригодится T* const в реальной жизни
const T* обычно «заходит» сразу: удобно в интерфейсе и хорошо выражает контракт «только чтение».
А T* const чаще полезен внутри реализации. Представьте, что вы внутри функции выбрали «текущую задачу» и дальше несколько раз с ней работаете. Вам важно случайно не переназначить указатель на другую задачу. Тогда вы можете «прибить» адрес:
#include <iostream>
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
int main() {
Task t{1, "Do not reassign pointer", false};
Task* const current = &t; // адрес фиксирован
current->done = true; // менять объект можно
// current = nullptr; // нельзя
std::cout << current->done << '\n'; // 1
}
То есть T* const — это в первую очередь защита от случайных переназначений, а не обязательный элемент публичного интерфейса.
7. Типичные ошибки
Ошибка №1: перепутать const T* и T* const.
Один const «не туда приклеили», и смысл стал противоположным. Лечится не зубрёжкой, а привычкой задавать два вопроса: «могу ли я сделать *p = ...?» и «могу ли я сделать p = ...?». Если один из ответов должен быть «нет» — ставим const туда, где оно это запрещает.
Ошибка №2: думать, что const T* делает объект глобально константным.
Нет, объект не превращается в ледышку. Он просто становится «ледышкой через этот конкретный доступ». Другой код всё ещё может менять объект, если у него есть неконстантный доступ. Это нормально: const — инструмент управления правами, а не магия запрета изменений во всей программе.
Ошибка №3: пытаться преобразовать const T* в T*.
Обычно это упирается в ошибку компиляции, и это хорошо: компилятор вас спасает. Если функция требует T*, она собирается менять объект. Значит, либо вы ошиблись и функция должна принимать const T*, либо вы действительно хотите менять — но тогда надо иметь неконстантный доступ с самого начала, а не «выкручивать руки» типам.
Ошибка №4: разыменовать указатель, который может быть nullptr.
Пока в типе у вас T*, nullptr — легальный кандидат (если вы не договорились иначе). Значит, разыменование без проверки — это лотерея. Даже если «вроде всегда приходит не nullptr», через месяц вы сами себе подложите сюрприз. Минимальная дисциплина: если параметр/переменная может быть пустой, проверка должна быть рядом с использованием.
Ошибка №5: использовать const как замену проектированию.
Иногда новичок ставит const «везде, где страшно», надеясь, что это сделает код безопасным. Но const отвечает только на вопрос «можно ли менять», он не решает вопрос «существует ли ещё объект» и «валиден ли адрес». Поэтому const — отличный контракт, но не бронежилет от всех проблем.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ