1. constexpr вместо макросов‑констант
Когда вы впервые видите, что #define MAX_USERS 100 «как бы работает», появляется соблазн: «О, так можно делать всё!». И в этот момент где‑то вдалеке плачет один компилятор, потому что макросы — это не C++‑сущности, а текстовая подстановка. Они не уважают области видимости, не имеют типа и могут менять смысл выражения из‑за приоритетов операторов. То есть макросы часто похожи на очень сильное заклинание: эффект впечатляет, но цена ошибки — внезапный фаербол по своим.
Главная идея сегодняшней лекции проста: если задачу можно выразить нормальными механизмами C++ (константами, функциями, шаблонами), то почти всегда лучше сделать именно так. Макрос оставляем на случаи, когда без препроцессора действительно теряется смысл.
Когда мы пишем object-like macro, он выглядит как «константа»:
#define MAX_TASKS 200
Но это не константа. Это правило: «встретил MAX_TASKS — подставь текст 200». Типа нет, области видимости нет, диагностика может быть странной.
Что даёт constexpr в роли константы проекта
Самое приятное: constexpr почти так же короток, но уже является честной сущностью C++. У него есть тип, его видно/не видно по правилам C++, и компилятор умеет ругаться понятнее.
Представим, что мы ведём наше учебное консольное приложение TodoLite (условный список задач). Нам нужна «настройка»: максимальное число задач, которое мы позволим хранить в памяти.
Сделаем заголовок include/app/config.hpp:
#pragma once
namespace app::config {
inline constexpr int MaxTasks = 200;
}
Здесь сразу два момента.
Во-первых, constexpr int — это реально «целое число», а не текст.
Во-вторых, добавлено inline, чтобы эту константу можно было безопасно положить в заголовок и подключать в нескольких .cpp (мы пока не уходим глубоко в теорию линковки, просто фиксируем практическое правило: «в заголовке — значит подумай про inline»).
Теперь в любом .cpp:
#include <iostream>
#include "app/config.hpp"
int main() {
std::cout << app::config::MaxTasks << '\n'; // 200
}
И это уже не «магия подстановки», а обычная типизированная константа, живущая в пространстве имён app::config.
Почему это важно новичку
На старте кажется: «Ну какая разница, 200 и 200». Но разница появляется, когда вы делаете ошибку.
Если вы случайно сделаете так:
#define MAX_TASKS "200"
То компилятор может выдать вам ошибку далеко не там, где вы ожидали, потому что в каком-то месте вместо числа внезапно окажется строковый литерал.
С constexpr так не получится:
namespace app::config {
inline constexpr int MaxTasks = "200"; // ошибка компиляции: тип не тот
}
Компилятор ругнётся прямо в точку: «нельзя строку присвоить int». Это та редкая ситуация, когда компилятор ведёт себя как заботливый преподаватель, а не как персонаж из хоррора.
3. Функции и шаблоны вместо function-like макросов
С function-like макросами всё становится ещё веселее, потому что они выглядят как функции, но ими не являются:
#define SQUARE(x) ((x) * (x))
В прошлой лекции мы уже обсуждали, что скобки здесь обязательны. Но даже со скобками остаётся проблема, которую невозможно «вылечить скобками»: аргумент может вычисляться несколько раз.
Проблема повторного вычисления
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int i = 2;
int y = SQUARE(i++); // i++ подставится дважды
std::cout << "i=" << i << " y=" << y << '\n'; // i=4 y=6 (или другая “магия”)
}
Тут даже неважно, какой именно результат вы получите (он может быть неожиданным и зависеть от деталей). Важно то, что смысл выражения стал «скользким»: i++ внезапно сработал два раза.
Замена: constexpr-функция
Теперь сделаем по-человечески:
constexpr int square(int x) {
return x * x;
}
И используем:
#include <iostream>
constexpr int square(int x) {
return x * x;
}
int main() {
int i = 2;
int y = square(i++); // i++ вычислится один раз
std::cout << "i=" << i << " y=" << y << '\n'; // i=3 y=4
}
Плюс этой версии даже не в constexpr как таковом. Главное — функция гарантирует, что аргумент вычисляется один раз. Это уже огромная победа над «текстовой магией».
Пример из мини‑проекта TodoLite: ограничение диапазона
Сейчас сделаем чуть более «прикладной» фрагмент для TodoLite, чтобы было видно: мы не просто играем в квадрат, а реально улучшаем код проекта.
Представим, что у задачи есть приоритет от 1 до 5, и мы хотим подстраховаться: если пользователь ввёл 999 — мы аккуратно «зажмём» значение до 5.
Новичок часто пишет макрос:
#define CLAMP(x, lo, hi) ((x) < (lo) ? (lo) : ((x) > (hi) ? (hi) : (x)))
Выглядит мощно, но опасно: аргументы могут вычисляться несколько раз (например, если там вызов функции), а ещё это тяжело читать.
Замена макросу: clamp как шаблон функции
Когда вам хочется сделать «универсальную штуку», макрос кажется удобным: он же «текст». Но как раз здесь C++ даёт нормальный инструмент — шаблон функции.
Сделаем заголовок include/app/util.hpp:
#pragma once
namespace app {
template <typename T>
constexpr T clamp(T x, T lo, T hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
} // namespace app
Заметьте два важных момента.
- Во-первых, это читается как обычный код: if, return, без тройных тернарных операторов-матрёшек.
- Во-вторых, аргументы вычисляются один раз, как у любой функции.
Теперь в коде TodoLite:
#include <iostream>
#include "app/util.hpp"
int main() {
int rawPriority = 999;
int priority = app::clamp(rawPriority, 1, 5);
std::cout << priority << '\n'; // 5
}
И это всё ещё компактно, но гораздо безопаснее, чем макрос.
Шаблон — часть языка C++. Он участвует в проверке типов, видит области видимости, даёт более понятные ошибки, чем «после подстановки текста».
Да, сообщения об ошибках в шаблонах иногда пугают новичков. Но в этой теме мы используем шаблон в самом простом виде: «одна функция, один typename T». Для начала этого достаточно.
4. inline: как жить с заголовками без макросов
Когда код становится многофайловым, возникает типичная ситуация: «я хочу определить что-то в заголовке, чтобы оно было доступно везде». Макросы это «решают» легко: определил в .hpp — и всё, оно подставляется в каждом .cpp.
Но у макросов за это есть цена: они «протекают» сквозь проект, как вода сквозь плохую крышу. Сегодня в одном файле нормально, завтра подключили другой заголовок — и внезапно MAX конфликтует с чужим MAX.
Практический шаблон: inline constexpr для настроек в config.hpp
Для TodoLite мы заведём несколько настроек в одном месте:
#pragma once
namespace app::config {
inline constexpr int MaxTasks = 200;
inline constexpr int MinPriority = 1;
inline constexpr int MaxPriority = 5;
}
А в логике добавления задачи:
#include "app/config.hpp"
#include "app/util.hpp"
int normalizePriority(int raw) {
return app::clamp(raw, app::config::MinPriority, app::config::MaxPriority);
}
Теперь всё типизировано, всё в namespace, всё под контролем.
inline-функции в заголовках
Если функция маленькая и вы хотите держать её прямо в заголовке (например, утилиту форматирования), то часто делают так:
#pragma once
#include <string>
namespace app {
inline std::string yesNo(bool value) {
return value ? "yes" : "no";
}
} // namespace app
Здесь inline означает: «эту функцию можно определять в заголовке, который включают много .cpp». Мы не углубляемся в формальные правила, нам важна практика: маленькие функции‑утилиты в заголовках — да, но делайте их inline.
5. Когда макрос всё-таки оправдан
Есть задачи, где макрос реально нужен, потому что он работает в месте вызова, а не в месте определения функции.
Самый классический пример — логирование с указанием файла и строки: __FILE__ и __LINE__. Это предопределённые макросы, которые подставляются препроцессором и дают информацию «где именно написан этот вызов». Это поведение стандартизовано, и такие макросы поддерживаются всеми нормальными компиляторами.
Гибрид: макрос захватывает место вызова, остальное делает функция
Это хороший компромисс: мы минимизируем роль макроса до того, что он делает лучше всего — «подставь файл и строку». А остальную логику отдаём C++‑функции.
Например, include/app/log.hpp:
#pragma once
#include <iostream>
namespace app {
inline void logLine(const char* file, int line, const char* msg) {
std::cout << file << ":" << line << " " << msg << '\n';
}
} // namespace app
#define APP_LOG(msg) ::app::logLine(__FILE__, __LINE__, (msg))
Макрос здесь маленький и «тупой»: он только прокидывает __FILE__ и __LINE__. Всё остальное — нормальный C++.
Использование:
#include "app/log.hpp"
int main() {
APP_LOG("start"); // main.cpp:5 start (пример)
APP_LOG("finish"); // main.cpp:6 finish (пример)
}
Да, это всё ещё макрос. Но это уже контролируемый макрос: он не пытается быть функцией, не делает вычислений, не содержит «сложную логику».
6. Шпаргалка: чем заменить макрос
Иногда новичку нужна не философия, а быстрый вопрос: «я сейчас хотел написать #define, что мне взять вместо него?». Давайте сделаем короткую таблицу, которую можно держать в голове.
| Что вы хотели сделать макросом | Плохой вариант | Хороший вариант в C++ | Почему лучше |
|---|---|---|---|
| Константа (число/лимит) | |
|
Есть тип, есть scope, диагностика лучше |
| Простое вычисление | |
|
Аргумент вычислится один раз |
| Обобщённое вычисление «для любого типа» | |
|
Типы проверяются компилятором |
| Многострочный «оператор» | сложный macro | обычная функция / несколько функций | Читаемость и отладка |
| Надо знать место вызова | «без вариантов» | маленький макрос + функция | Макрос берёт __FILE__/__LINE__, функция делает работу |
А теперь схема выбора — мини‑блок‑схема:
flowchart TD
A[Хочу написать #define] --> B{Это 'константа'?}
B -->|Да| C[inline constexpr / constexpr]
B -->|Нет| D{Это 'вычисление'?}
D -->|Да| E{Нужно для разных типов?}
E -->|Нет| F[constexpr/inline функция]
E -->|Да| G[template функция]
D -->|Нет| H{Нужно место вызова? __FILE__/__LINE__}
H -->|Да| I[маленький макрос -> вызывает inline функцию]
H -->|Нет| J[Скорее всего, макрос не нужен]
7. Типичные ошибки
Ошибка №1: заменить макрос на const, а потом «получить сюрприз» в многофайловом проекте.
Новичок убирает #define, пишет в заголовке const int MaxTasks = 200;, включает этот заголовок в несколько .cpp и удивляется странным сообщениям линковщика или неоднозначному поведению. Практическое лечение на нашем уровне простое: для «конфиг‑констант в заголовке» используйте inline constexpr и держите их в отдельном config.hpp.
Ошибка №2: написать функцию вместо макроса, но забыть, что иногда макрос захватывает «место вызова».
Когда вы хотите логировать «где произошло», простая функция log("msg") не узнает строку и файл вызова сама по себе: она увидит только то место, где она определена. Поэтому для __FILE__/__LINE__ оправдан гибрид: маленький макрос, который прокидывает метаданные в обычную функцию.
Ошибка №3: пытаться «сделать как в C» и писать сложную логику в макросе, потому что так короче.
Макрос на одну строку легко превращается в макрос на три строки, потом в десять, потом вы начинаете бояться его трогать, а через месяц — даже читать. Если внутри уже есть ветвления, циклы, несколько действий и особенно работа с контейнерами, то макрос почти наверняка неуместен: вы теряете типы, область видимости и адекватную отладку.
Ошибка №4: использовать макросы как «глобальные имена», не понимая, что они игнорируют namespace.
Можно аккуратно написать namespace app::config { ... }, а потом одним #define MaxTasks ... случайно всё сломать, потому что макрос подставится в любом месте текста ниже, даже внутри чужого пространства имён. Поэтому правило простое: макросы называйте заметно (обычно ALL_CAPS) и держите их влияние минимальным, а лучше — заменяйте на constexpr/inline/template.
Ошибка №5: заменить макрос на шаблон, а потом удивляться ошибкам компиляции «на полстраницы».
Да, шаблоны иногда ругаются многословно. Но в этой лекции мы используем их максимально мягко: один template <typename T> и простая логика. Если ошибка всё равно пугает, почти всегда помогает упростить вызов и проверить типы аргументов: шаблонный clamp ожидает, что тип умеет сравниваться через < и >, а вы случайно передали несовместимые типы или перепутали порядок аргументов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ