JavaRush /Курси /C++ SELF /Макроси #define: ризики, домовленості щодо імен

Макроси #define: ризики, домовленості щодо імен

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

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. Це дає дві переваги: по‑перше, ви одразу бачите, що це макрос; по‑друге, шанс конфлікту різко зменшується.

Ось невелика таблиця «поганих» і вдалих імен:

Погане імʼя макроса Чому погано Вдале імʼя
MAX
конфліктує майже з усім на світі
TT_MAX_TASKS
log
виглядає як функція, ускладнює читання
TT_LOG
DEBUG
надто загальне, часто вже зайняте
TT_DEBUG_MODE
min
/
max
класика конфліктів (особливо на Windows)
TT_MIN_VALUE

Ще один момент: макроси в заголовках — це як спеції. Трохи може поліпшити страву, але якщо висипати пів банки, їсти це вже буде неможливо. Якщо макрос усе-таки живе в .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 — проста дисципліна, яка економить години налагодження.

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