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++ | Чому краще |
|---|---|---|---|
| Константа (число/ліміт) | |
|
Є тип, є область видимості, діагностика зрозуміліша |
| Просте обчислення | |
|
Аргумент обчислиться один раз |
| Узагальнене обчислення «для будь-якого типу» | |
|
Типи перевіряються компілятором |
| Багаторядковий «оператор» | складний макрос | звичайна функція / кілька функцій | Читабельність і налагодження |
| Треба знати місце виклику | «без варіантів» | маленький макрос + функція | Макрос бере __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 очікує, що тип уміє порівнюватися через < і >, а ви випадково передали несумісні типи або переплутали порядок аргументів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ