1. Препроцесор і природа макросів
Якби C++ був серіалом, препроцесор був би персонажем, який зʼявляється в першій серії, каже дві репліки, а потім виявляється, що весь цей час він таємно керував сюжетом. Проблема в тому, що препроцесор працює до компілятора і виконує річ, яку програмісти часто недооцінюють: просто редагує текст. Жодних типів, жодних областей видимості C++, жодних перевантажень — лише «знайшов слово → замінив на інший текст».
Про це зручно думати так:
flowchart LR
A["Ваш текст .cpp/.hpp"] --> B["Препроцесор<br/>(#include, #define, ... )"]
B --> C["Підсумковий текст після підстановок"]
C --> D["Компілятор C++<br/>(типи, перевантаження, перевірки)"]
Саме тому помилки через макроси часто мають дивний вигляд: компілятор свариться на вже перетворений текст, а не на те, що ви написали власноруч.
Тут є важливий термінологічний нюанс: у «звичайному» C++ слово name (імʼя) стосується сутностей мови на пізніших етапах обробки, а макроси — це окремий всесвіт із macro names. У формулюваннях зі стандартизації прямо підкреслюється, що «імена» в строгому сенсі стосуються сутностей пізнішої фази обробки, а в макросів ідеться саме про macro names.
Це добре пояснює, чому namespace на макроси не поширюється і чому #define max 10 може зіпсувати життя всьому проєкту — і не лише йому.
2. Object-like макроси
Коли ви вперше бачите #define, може здатися, що це «просто константа». Але насправді це ближче до правила автозаміни в редакторі: зустріли NAME — підставили текст. І саме тут новачки часто роблять небезпечний висновок: «Ну, тоді зберігатиму константи так». Технічно можна. На практиці майже завжди є кращий варіант. Про хороші альтернативи поговоримо в наступних лекціях, а зараз нам важливо зрозуміти сам механізм і його ризики.
Object-like макрос — це «імʼя → текст»:
#define APP_NAME "TinyTasks"
#define DEFAULT_LIMIT 10
У нашому міні‑застосунку (нехай це буде проста консольна утиліта TinyTasks) можна зробити так:
#include <iostream>
#define APP_NAME "TinyTasks"
int main() {
std::cout << APP_NAME << '\n'; // TinyTasks
}
На етапі препроцесора APP_NAME просто перетвориться на "TinyTasks".
Тепер про головний підступ: пріоритет операторів і відсутність дужок. Макрос — це текст. І якщо цей текст містить вираз, пріоритети операторів спрацюють як завжди, але вже над «розгорнутим» текстом.
Ось приклад, який виглядає невинно:
#include <iostream>
#define TWO_PI 2 * 3.14159
int main() {
double r = 10.0;
std::cout << TWO_PI * r << '\n';
}
Людина читає це як (2 * 3.14159) * r. Компілятор теж прочитає це саме так, бо оператори * мають однаковий пріоритет, і все обчислюватиметься зліва направо. Але якби в макросі був складніший вираз — наприклад, із + або -, — без дужок ви доволі швидко отримали б математику з паралельного всесвіту.
Тому перше правило безпеки для object-like макросів, які є виразом: обгортати їх у дужки. Не тому, що «так заведено», а тому, що макрос — це вставка тексту, а не вираз як сутність мови.
3. Function-like макроси
Function-like макроси — це ті, що виглядають як виклик функції: NAME(x). Новачки їх часто люблять: «О! Зараз зроблю собі SQUARE(x) і буду щасливий». Проблема в тому, що це не функція. Це лише «підстав аргумент у шаблон тексту».
Почнімо з класики:
#include <iostream>
#define SQUARE(x) (x * x)
int main() {
std::cout << SQUARE(3) << '\n'; // 9
}
Здається, усе нормально. Але ось так:
#include <iostream>
#define SQUARE(x) (x * x)
int main() {
std::cout << SQUARE(1 + 2) << '\n'; // 5 (Ой)
}
Чому 5? Бо макрос розгорнеться в (1 + 2 * 1 + 2). Пріоритет множення вищий, тож вийде зовсім не те, що ви собі уявляли.
Тому для function-like макросів є «формула-оберіг», яка рятує від величезної кількості граблів:
#define SQUARE(x) ((x) * (x))
Тобто дужки ставлять навколо кожного використання аргументу і навколо всього результату. Це не питання естетики — це спроба зробити «копіпасту тексту» бодай трохи схожою на вираз.
4. Побічні ефекти й подвійне обчислення
Якщо попередній підступ стосувався пріоритету операторів, то цей — того, що function-like макрос може використовувати аргумент кілька разів, а отже — і обчислювати його кілька разів. У функції аргумент обчислюється один раз — перед передаванням у функцію. У макросі ж аргумент просто підставляється в текст скрізь, де ви його написали.
Спочатку визначімо «побічний ефект» максимально просто: це коли вираз не лише обчислює значення, а ще й змінює стан. Наприклад, i++ змінює i.
Подивіться:
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int i = 2;
int y = SQUARE(i++);
std::cout << "i=" << i << " y=" << y << '\n'; // i=4 y=6 (або ще веселіше)
}
Макрос розгортається в ((i++) * (i++)). Тобто інкремент відбудеться двічі. А далі починається «атракціон стандарту»: порядок обчислення частин виразу може виявитися не таким, як ви очікуєте, і для непідготовленої людини результат стає непередбачуваним.
І тут корисно запамʼятати просту практичну мораль: у function-like макроси не можна передавати вирази з побічними ефектами, якщо макрос використовує аргумент більш ніж один раз. А новачки майже завжди забувають перевірити, чи використовує макрос аргумент один раз, чи два. Саме тому function-like макроси в реальному коді намагаються не створювати без крайньої потреби.
5. Багаторядкові макроси та do { ... } while(false)
Іноді макрос хочуть використати не як вираз, а як «команду»: наприклад, вивести лог, перевірити умову, надрукувати повідомлення. І тут зʼявляється наступний рівень болю: якщо макрос розгортається в кілька операторів, він може зламати if/else (і ви потім довго дивитиметеся на помилку компіляції так, ніби це else образився).
Уявімо наївний макрос логування:
#define LOG(msg) std::cout << msg << '\n'; std::cout << "----\n";
Якщо ви напишете:
if (ok)
LOG("start");
else
std::cout << "error\n";
то else може «прилипнути» не туди. Бо після розгортки макроса вийде два оператори, і if стосуватиметься лише першого.
Тому існує стандартна ідіома: обгортати макрос у do { ... } while(false), щоб він завжди був одним оператором:
#include <iostream>
#define LOG(msg) do { \
std::cout << (msg) << '\n'; \
std::cout << "----\n"; \
} while (false)
int main() {
bool ok = true;
if (ok)
LOG("start"); // start
// ----
else
std::cout << "error\n";
}
Так, це виглядає як «магічний ритуал». Але сенс тут дуже прагматичний: do { ... } while(false) — це один оператор, а отже, if/else поводиться передбачувано.
6. Обмеження області дії та імена макросів
#undef і зона ураження
Макроси не знають про області видимості C++. Вони не розуміють, що таке блок {}, не поважають namespace і не відчувають сорому, коли ламають чужий код. Тому корисний прийом — обмежувати «зону ураження».
Коли ви робите #define, макрос діє далі в тексті, доки не закінчиться файл (після всіх вставок через #include) або доки ви не скасуєте його через #undef.
Наприклад:
#include <iostream>
#define TMP_VALUE 123
int main() {
std::cout << TMP_VALUE << '\n'; // 123
}
#undef TMP_VALUE
На практиці #undef частіше застосовують не в main.cpp, а в ситуаціях на кшталт «ми визначили тимчасовий макрос для якогось заголовка або фрагмента коду й не хочемо, щоб він витік далі». Особливо це актуально, коли макрос живе в заголовку: заголовок можуть підключити сотні .cpp файлів, і кожен отримає цей макрос як «подарунок».
Домовленості щодо імен
З іменами макросів варто бути параноїдально обережними. Не тому, що «такий стиль», а тому, що макроси — це глобальна текстова підстановка, і ймовірність конфлікту імен із чужим кодом справді висока. У документах зі стандартизації окремо підкреслюється, що в макросів своя «система імен» (macro names), і це не ті імена, якими оперує C++ на пізніших фазах.
Домовмося про просте правило для нашого проєкту TinyTasks: усі макроси починаються з префікса проєкту TT_ і пишуться в ALL_CAPS. Це дає дві переваги: по‑перше, ви одразу бачите, що це макрос; по‑друге, шанс конфлікту різко зменшується.
Ось невелика таблиця «поганих» і вдалих імен:
| Погане імʼя макроса | Чому погано | Вдале імʼя |
|---|---|---|
|
конфліктує майже з усім на світі | |
|
виглядає як функція, ускладнює читання | |
|
надто загальне, часто вже зайняте | |
/ |
класика конфліктів (особливо на Windows) | |
Ще один момент: макроси в заголовках — це як спеції. Трохи може поліпшити страву, але якщо висипати пів банки, їсти це вже буде неможливо. Якщо макрос усе-таки живе в .hpp, його імʼя має бути дуже акуратним і унікальним.
7. Практичний приклад: макро‑логер TT_LOG
Зараз розглянемо приклад, який показує «виправданий» сценарій для макроса: передати місце виклику (файл і рядок). Це саме та річ, яку звичайна функція не може «сама» отримати без втрати сенсу: важливе саме місце, де ви її викликали.
Ми додамо в TinyTasks простий логер, який друкує повідомлення у форматі:
file:line message
Заголовок logger.hpp
Почнімо з простого: заголовок оголошує функцію і містить макрос-обгортку.
// logger.hpp
#pragma once
#include <string>
namespace tt {
void log(const std::string& message, const char* file, int line);
}
#define TT_LOG(msg) tt::log((msg), __FILE__, __LINE__)
Зверніть увагу на дві речі.
Перша: макрос TT_LOG(msg) перетворюється на виклик функції, тому аргумент msg обчислиться рівно один раз — як звичайний аргумент функції. Це набагато безпечніше, ніж «хитрі» макроси на кшталт SQUARE(x).
Друга: ми використовуємо __FILE__ і __LINE__ прямо в місці виклику макроса. Саме тому тут і потрібен макрос: він вставить __FILE__/__LINE__ саме туди, де ви написали TT_LOG(...).
Реалізація logger.cpp
Реалізація максимально проста: просто друкуємо рядок.
// logger.cpp
#include "logger.hpp"
#include <iostream>
namespace tt {
void log(const std::string& message, const char* file, int line) {
std::cout << file << ":" << line << " " << message << '\n';
}
}
Використання в main.cpp
Тепер підключімо логер і використаймо його в нашому застосунку.
// main.cpp
#include "logger.hpp"
#include <iostream>
int main() {
TT_LOG("TinyTasks started"); // main.cpp:6 TinyTasks started (приблизно)
std::cout << "Hello from app\n"; // Hello from app
}
Номери рядків, звісно, залежатимуть від того, де саме стоїть виклик. І в цьому весь сенс: лог містить реальне місце події.
Зауважте, що тут макрос справді доречний: він не замінює можливості мови, а лише передає метадані про місце виклику. У наступних лекціях ми обговорюватимемо, як зменшувати використання макросів, але цей приклад — саме той випадок, коли макрос виглядає виправдано.
8. Типові помилки під час роботи з #define
Помилка № 1: думати про макрос як про змінну або константу з типом.
Новачок пише #define LIMIT 10, а далі починає міркувати: «LIMIT — це int». Ні: це текст 10, який буде вставлено в код. Тип зʼявиться лише після розгортки, коли компілятор побачить підсумковий текст. Через це макрос може несподівано «спрацювати» в місцях, де ви цього не планували (наприклад, усередині іншого ідентифікатора, якщо діяти неакуратно).
Помилка № 2: function-like макрос без дужок.
Запис #define SQUARE(x) x*x рано чи пізно ламається на SQUARE(a + b) і перетворюється на підручник із пріоритетів операторів, якого ви не замовляли. Дужки навколо аргументів і результату — це мінімальна страховка, якщо ви все-таки пишете такий макрос.
Помилка № 3: передавати в макрос вирази з побічними ефектами.
SQUARE(i++) — класичний приклад, де аргумент підставиться двічі й стан зміниться двічі. Навіть якщо код «ніби працює» в одній збірці, це не привід довіряти такому рішенню. Макрос — це копіпаста, а копіпаста не розуміє, що означає «виконай один раз».
Помилка № 4: багаторядковий макрос без do { ... } while(false).
Якщо макрос розгортається в кілька операторів, він може зламати if/else, і ви отримаєте помилки компіляції в стилі «else without a previous if», хоча if у вас був. Ідіома do { ... } while(false) робить макрос одним оператором і повертає передбачуваність.
Помилка № 5: надто загальні імена та відсутність префікса проєкту.
Макроси не поважають namespace, тому #define MAX ... або #define log ... — надійний спосіб наразитися на конфлікти з бібліотеками, платформними заголовками й навіть із вашим же кодом через місяць. Префікс проєкту (TT_...) і ALL_CAPS — проста дисципліна, яка економить години налагодження.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ