1. const как контракт доступа
const в параметрах — это «только чтение»
Когда вы пишете функцию, вы фактически предлагаете остальным программистам (и себе через две недели) маленький договор: что функция сделает с переданными данными. Проблема в том, что без явных подсказок в сигнатуре этот договор получается на уровне «ну я вроде не собирался ничего ломать… честно-честно». В C++ так нельзя: язык суровый, как отчёт компилятора в 3 ночи.
const в параметрах — это способ сказать: «я беру доступ только для чтения». И это не «замораживает» объект в целом. Объект может быть изменён через другое имя, но именно через этот параметр вы менять не сможете. Это и есть «контракт доступа».
Представьте библиотеку: const — это читательский билет. С ним вы можете листать книгу, но не можете дорисовать усы на портрете в учебнике истории (хотя желание бывает сильным).
Главная четвёрка параметров
Сигнатура функции — это первое, что видит пользователь вашего кода. И очень хочется, чтобы она была честной. В процедурном стиле (без классов и «умных» интерфейсов) у нас есть четыре основных инструмента, которыми мы выражаем смысл.
Ниже — таблица, которую стоит держать в голове. Она не «для зубрёжки», а чтобы мозг перестал гадать, а начал узнавать.
| Параметр | Объект обязателен? | Можно менять объект? | Типичный смысл |
|---|---|---|---|
|
да | нет | «читаю, без копии» |
|
да | да | «буду менять» |
|
нет (p может быть nullptr) | нет | «может не быть объекта, если есть — читаю» |
|
нет (nullptr) | да | «может не быть объекта, если есть — могу менять» |
Важно: ссылки (&) обычно означают «объект обязателен». Указатель (*) обычно означает «объект может отсутствовать». Это не правило «по стандарту», но это очень устойчивая и полезная договорённость в реальном коде.
Практический пример: модель Task
Чтобы примеры не были сферическими int a = 5;, продолжим идею простого консольного мини‑приложения «Список задач». Никаких классов, только struct и функции — так мы честно тренируем именно сигнатуры.
Начнём с модели задачи:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Здесь уже видно, что std::string копировать «просто так» не хочется, а значит const Task& и const std::string& скоро станут нашими лучшими друзьями.
2. Параметры-ссылки: const T& и T&
const T&: читаю без копии
Когда функция должна посмотреть на объект, но не менять его, чаще всего вы хотите const T&. Это одновременно и «быстро» (без копии), и «честно» (компилятор не даст менять), и «удобно» (вы можете передать как переменную, так и временный объект в ряде случаев).
Напишем печать одной задачи:
#include <iostream>
#include <string>
void PrintTask(const Task& t) {
std::cout << "#" << t.id << " " << t.title;
std::cout << (t.done ? " [done]\n" : " [todo]\n");
// пример: #1 Buy milk [todo]
}
Обратите внимание: сигнатура уже говорит правду. Любой, кто вызовет PrintTask, не будет переживать, что задача внезапно «сама выполнится» просто из-за печати.
Теперь печать всего списка:
#include <vector>
void PrintTasks(const std::vector<Task>& tasks) {
for (const Task& t : tasks) {
PrintTask(t);
}
}
Здесь const std::vector<Task>& особенно важен: копировать вектор задач ради печати — это как распечатывать Википедию, чтобы узнать дату рождения Шелдона.
T&: собираюсь менять, и это видно
Иногда функция должна менять объект. И тут нет смысла притворяться. Чем честнее сигнатура, тем меньше сюрпризов.
Сделаем функцию, которая помечает задачу выполненной:
void MarkDone(Task& t) {
t.done = true;
}
Если бы мы сделали const Task&, код бы не скомпилировался (и это прекрасно). Компилятор в этот момент выступает вашим напарником по команде: «ты сказал, что не меняешь — значит не меняешь».
А теперь функция, которая добавляет задачу в список. Список мы меняем, значит нужен std::vector<Task>&. Название задачи мы не собираемся менять внутри функции, значит const std::string&.
#include <vector>
#include <string>
void AddTask(std::vector<Task>& tasks, int id, const std::string& title) {
Task t;
t.id = id;
t.title = title;
tasks.push_back(t);
}
Да, тут есть копирование строки в t.title — но это уже «рабочее» копирование: мы действительно сохраняем данные в задачу.
3. Параметры-указатели: const T* и T*
const T*: объекта может не быть, и я только читаю
Теперь подходим к самой полезной паре: const T* как способ выразить «я могу вернуть/принять ссылку на объект, но он может отсутствовать».
Классический пример в нашем приложении: «найти задачу по id». Если не нашли — что делать? Можно вернуть nullptr.
#include <vector>
const Task* FindTaskById(const std::vector<Task>& tasks, int id) {
for (const Task& t : tasks) {
if (t.id == id) return &t;
}
return nullptr;
}
Здесь есть важная смысловая победа: функция возвращает «читательский доступ» к задаче. То есть вызывающий код не сможет сделать found->done = true; — и это правильно, потому что он получил «только посмотреть».
Использование выглядит так:
#include <iostream>
void PrintTaskById(const std::vector<Task>& tasks, int id) {
const Task* p = FindTaskById(tasks, id);
if (p == nullptr) {
std::cout << "Task not found\n"; // Task not found
return;
}
PrintTask(*p);
}
Отдельно обратите внимание на ритуал if (p == nullptr) return;. Это не «лишняя проверка», это часть контракта: раз тип допускает отсутствие, код обязан это обработать.
T*: объекта может не быть, но если есть — можно менять
Иногда мы хотим найти задачу и изменить её. Тогда возвращаем Task* (не const Task*).
#include <vector>
Task* FindTaskByIdMut(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) return &t;
}
return nullptr;
}
Теперь можно написать функцию «пометить задачу выполненной по id»:
#include <iostream>
bool MarkDoneById(std::vector<Task>& tasks, int id) {
Task* p = FindTaskByIdMut(tasks, id);
if (p == nullptr) return false;
p->done = true;
return true;
}
Обратите внимание на возвращаемый bool: мы не усложняем сегодня обработку ошибок (без исключений), просто честно говорим «успех/неуспех».
4. Возврат const&: удобно, но с контрактом времени жизни
Возврат const& — штука мощная. Она позволяет вернуть доступ к уже существующим данным без копии, но накладывает на вас обязанность: вернуть ссылку на объект, который точно продолжит жить, пока он нужен вызывающему коду.
Звучит страшно? Немного. Но на практике это часто «просто удобно», особенно со строками.
Например, хотим получить заголовок задачи. Заголовок — это std::string, копировать его при каждом запросе может быть дорого (хотя иногда это нормально). Можно вернуть const std::string&.
#include <string>
const std::string& TitleOf(const Task& t) {
return t.title;
}
Использование:
#include <iostream>
void PrintTitle(const Task& t) {
std::cout << TitleOf(t) << "\n"; // Buy milk
}
Почему это безопасно? Потому что TitleOf возвращает ссылку на поле t.title, а t передан по const & и гарантированно существует во время вызова. Главное правило: вызывающий код не должен хранить эту ссылку дольше, чем живёт t.
Если вы поймали себя на мысли «я сохраню ссылку на title куда-нибудь глобально и буду пользоваться вечно» — это уже сигнал, что контракт не подходит. В таких сценариях безопаснее вернуть строку по значению.
Когда const& в возврате не нужно
У новичков бывает два крайних режима.
Первый режим: «везде const&, потому что быстрее». Это обычно приводит к ссылкам на временные объекты и к загадочным проблемам.
Второй режим: «везде по значению, мне так проще». Это обычно работает, но иногда неожиданно тормозит, когда вы начинаете активно таскать std::string/std::vector туда-сюда.
Здоровый подход в процедурном коде такой: возвращайте const&, когда вы точно возвращаете ссылку на часть уже существующего объекта, и это даёт реальную ясность и/или экономит большие копии. Но если логика функции «строит результат» (например, склеивает строку, делает фильтрацию, вычисляет новое значение), то ссылка почти всегда будет ошибкой по смыслу — результат ведь «новый», ему негде жить, кроме как в возвращаемом значении.
Вот пример функции, где нельзя возвращать ссылку, потому что строка создаётся внутри:
#include <string>
std::string MakeLabel(const Task& t) {
return "#" + std::to_string(t.id) + " " + t.title;
}
Здесь возврат по значению — честный и безопасный контракт: функция производит новое значение.
5. Шпаргалка: как выбрать сигнатуру
Когда вы пишете новую функцию, очень легко зависнуть на выборе параметров. Чтобы не превращать это в гадание на кофейной гуще (кофе, кстати, тоже конечный ресурс), полезно держать простую схему.
flowchart TD
A["Нужна функция"] --> B{"Нужно менять объект?"}
B -->|Нет| C{"Объект может отсутствовать?"}
B -->|Да| D{"Объект может отсутствовать?"}
C -->|Нет| E["Параметр: const T&"]
C -->|Да| F["Параметр: const T* (проверка на nullptr)"]
D -->|Нет| G["Параметр: T&"]
D -->|Да| H["Параметр: T* (проверка на nullptr)"]
Эта схема не «заменяет мышление», но отлично экономит время, потому что заставляет задать два правильных вопроса.
6. Типичные ошибки
Ошибка №1: передавать T&, когда функция не меняет объект.
Это часто выглядит «безобидно», пока не всплывает реальная проблема: такую функцию нельзя вызвать с const‑объектом, и она даёт вызывающему коду ложный сигнал «меня могут поменять». Исправление простое: если изменения не нужны — используйте const T&.
Ошибка №2: принимать const T*, но не проверять nullptr.
Если вы выбрали указатель как «объект может отсутствовать», вы обязаны обработать отсутствие в теле функции. В противном случае *p или p->field превращаются в билет на аттракцион «непредсказуемое поведение». Ссылка в этом плане честнее: она не допускает пустоты.
Ошибка №3: возвращать T*, хотя наружу нужен только просмотр.
Если вы отдаёте T*, вы отдаёте право изменения. Даже если вы «по договорённости попросите не менять», кто-нибудь обязательно поменяет (обычно это будете вы через месяц). Если хотите только чтение — возвращайте const T* или const T& (если отсутствие результата невозможно).
Ошибка №4: возвращать const& на временный объект.
Ссылка должна ссылаться на что-то живущее достаточно долго. Если внутри функции создаётся объект (например, строка), возвращать на него ссылку нельзя: объект уничтожится при выходе из функции. Если результат «вычисленный» или «собранный» — возвращайте по значению.
Ошибка №5: считать, что const гарантирует безопасность ссылки/указателя навсегда.
const запрещает изменения через этот доступ, но не обещает, что объект будет жить вечно и что адрес останется прежним. Контракт времени жизни — отдельная тема, и к ней нужно относиться внимательно: ссылка может быть const, но всё равно стать невалидной, если объект исчез.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ