JavaRush /Курси /C++ SELF /constexpr / inline / template замість макросів

constexpr / inline / template замість макросів

C++ SELF
Рівень 28 , Лекція 3
Відкрита

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++ Чому краще
Константа (число/ліміт)
#define MAX 10
inline constexpr int Max = 10;
Є тип, є область видимості, діагностика зрозуміліша
Просте обчислення
#define SQR(x) ...
constexpr int sqr(int x)
Аргумент обчислиться один раз
Узагальнене обчислення «для будь-якого типу»
#define MAX(a,b) ...
template <typename T> T max(T a, T b)
Типи перевіряються компілятором
Багаторядковий «оператор» складний макрос звичайна функція / кілька функцій Читабельність і налагодження
Треба знати місце виклику «без варіантів» маленький макрос + функція Макрос бере __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 очікує, що тип уміє порівнюватися через < і >, а ви випадково передали несумісні типи або переплутали порядок аргументів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ