JavaRush /Курси /C++ SELF /Налагодження макросів

Налагодження макросів

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

1. Макроси ламаються «не там, де болить»

Коли ви вперше натрапляєте на помилку через макрос, відчуття зазвичай таке: «Я змінив один рядок… а компілятор кричить десь в іншому місці… і ще пише щось про expected ')'… і взагалі, здається, він мене не любить». Це нормально. Препроцесор уміє геніально робити рівно одну річ: перетворювати простий код на незрозумілий, якщо користуватися ним необережно.

Головна причина в тому, що компілятор бачить не ваш вихідний код, а те, що вийшло після препроцесора. Ви читаєте файл main.cpp очима людини, а компілятор — зведений текст із main.cpp та всіх підключених заголовків, де вже розгорнуто макроси й вирізано фрагменти умовної компіляції. І повідомляє про помилки він уже в цьому підсумковому тексті.

Тому перша практична навичка налагодження макросів така: уміти зрозуміти, у що саме розгорнувся код. Не «приблизно», а хоча б на рівні «ага, тут підставилася дужка / крапка з комою / аргумент обчислився двічі».

Що таке «препроцесований файл»

Словосполучення «препроцесований файл» звучить так, ніби це секретний артефакт із підземель компілятора. Насправді це цілком прозаїчна річ: текст, який вийшов після роботи препроцесора, тобто після #include, розгортання #define та оброблення #if/#ifdef.

Можна сказати ще простіше: препроцесований файл — це «як виглядав би ваш .cpp, якби ви вручну вставили всередину всі #include, замінили всі макроси на їхній текст і прибрали неактивні гілки #if».

У стандарті C++ навіть використовується термін «preprocessing directives» (директиви препроцесора) як окрема категорія рядків, які обробляються на етапі препроцесування. Це ще раз підкреслює: препроцесор — окремий шар зі своїми правилами.

Важливо розуміти один тонкий момент: препроцесований файл часто містить службові рядки на кшталт #line (або подібні маркери), щоб компілятор міг повідомляти про помилки в термінах оригінальних файлів і рядків. Але за суттю це все одно той самий «підсумковий текст», який компілятор реально розбирає.

2. Ментальна модель: «пʼять хвилин побути препроцесором»

Щоб упевнено налагоджувати макроси, не потрібно вміти писати макро-метапрограми (і дякувати всім богам читабельності). Потрібно інше: навчитися на маленькому прикладі симулювати препроцесор.

Уявіть, що ви — препроцесор, і у вас лише три суперсили:

  1. якщо бачите #include "x.hpp" — вставляєте сюди вміст x.hpp;
  2. якщо бачите #define NAME текст — запамʼятовуєте правило «NAME → текст»;
  3. якщо далі в тексті трапляється NAME — просто замінюєте його на текст.

Подивімося на іграшковий приклад:

#include <iostream>

#define HELLO "Hi"

int main() {
    std::cout << HELLO << '\n';
}

Після роботи препроцесора (дуже грубо) вийде так:

// ...тут величезне простирадло з <iostream>...

int main() {
    std::cout << "Hi" << '\n';
}

І це вже пояснює половину «дивностей»: якщо у вас десь #define X ..., то компілятор ніколи не побачить «X» — він побачить те, на що перетворився X.

3. Міні-демонстрація: DEBUG-лог через макрос

Щоб мати спільну «пісочницю», продовжимо умовний консольний застосунок TaskBoard: він зберігає список завдань (вектор структур), уміє друкувати їх і виконувати прості операції. Ми не додаватимемо нову бізнес-логіку, а зосередимося на діагностиці.

Зробімо невеликий заголовок debug.hpp, який додає макрос TRACE(...). Спочатку це виглядає зручно: ми хочемо швидко друкувати «де я зараз перебуваю» і не писати щоразу довгий вираз із std::cout.

// debug.hpp
#pragma once
#include <iostream>

#define APP_DEBUG 1

#if APP_DEBUG
#define TRACE(msg) do { std::cout << __FILE__ << ":" << __LINE__ << " " << (msg) << '\n'; } while (false)
#else
#define TRACE(msg) do { } while (false)
#endif

Зверніть увагу, чому тут do { ... } while(false). Ми вже обговорювали, що так макрос виглядає як «один оператор» і нормально вбудовується в if/else без сюрпризів.

Тепер у main.cpp додамо кілька трасувань:

#include <iostream>
#include "debug.hpp"

int main() {
    TRACE("start");              // main.cpp:5 start   (приблизний вивід)
    std::cout << "Work...\n";    // Work...
    TRACE("finish");             // main.cpp:7 finish
}

З погляду препроцесора TRACE("start") — це не виклик функції. Це просто підстановка тексту. Дуже грубо підсумковий код матиме такий вигляд:

do {
    std::cout << __FILE__ << ":" << __LINE__ << " " << ("start") << '\n';
} while (false);

А потім уже __FILE__ і __LINE__ підставляються конкретними значеннями «місця виклику». І ось що важливо: вони беруться саме в місці використання макроса, а не в місці оголошення. Тому подібну річ звичайною функцією без макроса (у тому самому вигляді) не відтворити без втрати сенсу «точки виклику».

4. Коли допомагає препроцесований файл

До цього моменту все здається доволі логічним: макрос — підстановка, #include — вставка. Проблеми починаються, коли макрос підставляє щось несподіване: зайву дужку, відсутню дужку, дворазове обчислення аргументу, шматок ;, який ламає if/else.

І тут «препроцесований файл» — майже як рентгенівський знімок. У вас болить «десь тут», а знімок показує: «ось перелом».

Уявімо класичну пастку. Хтось (можливо, ви о другій годині ночі) написав макрос квадрата:

#define SQUARE(x) x * x

А потім використав його так:

int a = 2;
int b = 3;
int y = SQUARE(a + b);
std::cout << y << '\n';

Людина читає це як (a + b)² = 25. Але препроцесор підставляє текст буквально, і виходить:

int y = a + b * a + b;

Компілятор чесно застосовує пріоритет: * сильніший за +, — і отримує зовсім не те. І якщо ви відкриєте препроцесований результат, то буквально побачите цей вираз у підсумковому тексті. Не «можливо», не «здається», а «ось він».

Правильний, безпечніший варіант (який ми вже обговорювали) виглядає так:

#define SQUARE(x) ((x) * (x))

І тоді підсумковий текст буде передбачуванішим.

5. Аргумент обчислюється двічі

Є граблі навіть підступніші, ніж пріоритет операторів: повторне обчислення аргументу. У функції аргумент обчислюється один раз, а в макросі — стільки разів, скільки ви його написали в тілі підстановки.

Знову короткий приклад:

#include <iostream>

#define SQUARE(x) ((x) * (x))

int main() {
    int i = 2;
    int y = SQUARE(i++);     // ⚠️ i++ підставиться двічі
    std::cout << "i=" << i << " y=" << y << '\n';
}

Якщо подумки «розгорнути» макрос, вийде приблизно таке:

int y = ((i++) * (i++));

І далі поведінка стає несподіваною для новачка: i збільшиться двічі, а y вийде не тим, чого ви очікували. І це якраз той випадок, коли препроцесований файл допомагає одразу побачити причину: «ага, i++ справді стоїть двічі».

Тут логіка налагодження проста й майже доросла: якщо ви бачите «дивну математику», перевірте, чи не перетворює макрос один вираз на два.

6. Практика: помилки, читання виводу та алгоритм

Чому компілятор іноді вказує «не туди»

Коли макроси активно беруть участь у коді, повідомлення компілятора може виглядати як знущання: такий-то рядок, а у вас там «просто TRACE». Це відбувається тому, що компілятор діагностує проблему в підсумковому тексті.

Але щоб вам було трохи простіше, препроцесор і компілятор зазвичай намагаються зберігати привʼязку до оригінальних файлів і рядків. Саме тому в препроцесованому виводі (у реальному житті) часто трапляються службові рядки, які повідомляють: «наступні рядки прийшли з такого-то файла», «зараз ми перемкнулися на інший include», «а тепер повернулися назад». Це допомагає компілятору формулювати помилки в термінах вихідного коду.

Практично це означає таке. Якщо помилка вказує на рядок із макросом, часто корисно поставити собі запитання: «у що він розгорнувся?» — і подивитися на розгортання. Іноді виявляється, що проблема не в місці виклику, а в тому, що макрос підставив синтаксично кривий фрагмент (наприклад, десь загубилася дужка).

Як читати препроцесований результат і не потонути

Є одна проблема: якщо ви хоч раз бачили препроцесований вивід після #include <iostream>, то знаєте, що це не файл, а роман у жанрі «технічний горор». Тому підходити треба обережно: ми не читаємо все підряд, а шукаємо конкретний фрагмент.

Уявіть практичну ситуацію. У вас є рядок:

TRACE("finish");

І ви хочете зрозуміти, що саме бачить компілятор. У препроцесованому вигляді вас цікавить не весь світ, а конкретна «вставка», яка зʼявиться замість TRACE(...). Тобто ви шукаєте в тексті «шматок, схожий на std::cout» поруч з очікуваним місцем.

Для вправи зручно тримати в голові такий «шаблон розгортання»:

Вихідний код Після препроцесора (ідея)
TRACE("x")
do { std::cout << __FILE__ << ":" << __LINE__ << " " << ("x") << '\n'; } while(false);
#if APP_DEBUG
або блок залишається, або зникає
#include "debug.hpp"
вміст заголовка “вставився” сюди

Найкорисніше тут ось що: ви перестаєте сперечатися з реальністю. Замість «я думав, що макрос — як функція» у вас зʼявляється: «макрос — це текст; ось текст; компілятор бачить саме його».

Алгоритм налагодження макросів

Коли щось ламається через макроси, хочеться або видалити всі макроси з проєкту й поїхати в гори, або почати ставити дужки взагалі всюди, включно з рядками. Але є спокійніший підхід.

Спочатку ви фіксуєте місце, де «болить»: рядок помилки компілятора або місце, де поведінка стала дивною. Потім знаходите, які макроси беруть участь у цьому виразі. Іноді це очевидно (TRACE, SQUARE), іноді це макрос із заголовка, про який ви навіть не памʼятаєте (це вже окрема класика).

Далі ви робите найважливіше: розгортаєте макрос подумки на невеликому фрагменті. Якщо подумки не виходить — шукаєте спосіб подивитися препроцесований результат (в IDE, у налаштуваннях збирання, у режимі «зберегти проміжні файли» — як саме, залежить від середовища, але ідея одна).

Майже завжди причина ховається в одній із трьох категорій: пріоритет операторів, повторне обчислення аргументів або синтаксична «дірка» (пропущена дужка, зайва ;, дивна кома). І тоді правка зазвичай теж очевидна: додати дужки, переписати макрос на constexpr-функцію або зробити макрос «одним оператором» через do { ... } while(false).

7. Рефакторинг: макрос лише для місця виклику

Щоб закріпити ідею «макрос — виняток», поліпшімо наш debug.hpp.

Зробімо так: залишимо макрос лише для того, що справді потребує препроцесора (__FILE__/__LINE__ у місці виклику), а всю «звичайну» роботу віддамо функції. Це зменшує ризик того, що аргументи обчисляться двічі або що ви забудете дужки.

// debug.hpp
#pragma once
#include <iostream>
#include <string_view>

inline void trace_impl(const char* file, int line, std::string_view msg) {
    std::cout << file << ":" << line << " " << msg << '\n';
}

#define TRACE(msg) do { trace_impl(__FILE__, __LINE__, (msg)); } while(false)

Зверніть увагу на важливу «мораль»: тепер макрос лише передає __FILE__/__LINE__ з місця виклику, а друк виконує звичайна C++-функція. Аргумент msg обчислиться один раз (як аргумент функції), і це вже значно спокійніше.

8. Типові помилки

Помилка № 1: намагатися налагоджувати макрос як функцію.
Це найпоширеніша пастка мислення. Ми бачимо SQUARE(x) і автоматично думаємо: «виклик». Але макрос — це підстановка. Якщо тримати в голові «компілятор бачить уже підставлений текст», стає простіше розуміти і дивні обчислення, і дивні повідомлення про помилки.

Помилка № 2: дивитися на рядок помилки й ігнорувати розгортання макроса.
Компілятор часто лається на рядок із TRACE(...), хоча проблема в тому, що TRACE перетворився на синтаксично некоректну конструкцію. У таких випадках корисно перемкнутися із запитання «де помилка?» на запитання «який текст вийшов після препроцесора?».

Помилка № 3: писати функцієподібний макрос без дужок навколо аргументів і результату.
Макрос #define SQUARE(x) x*x виглядає невинно, доки не зʼявляється SQUARE(a + b). Тоді раптом зʼясовується, що пріоритет * сильніший за +, і вираз змінює сенс. Дужки ((x) * (x)) — це не параноя, а техніка безпеки.

Помилка № 4: передавати в макрос вирази з побічними ефектами.
i++, виклик функції, читання з потоку — усе це змінює стан. Якщо макрос використовує аргумент двічі, ви отримуєте «подвійну зміну» і дивні результати. Якщо вже дуже хочеться передати щось «нечисте», краще зупинитися й переписати це на функцію.

Помилка № 5: робити макрос «багаторядковим», але не перетворювати його на один оператор.
Коли макрос розгортається в кілька операторів і вставляється всередину if без фігурних дужок, можна випадково «відірвати» else або отримати іншу синтаксичну кашу. Конструкція do { ... } while(false) робить поведінку набагато передбачуванішою.

1
Опитування
Препроцесор і макроси, рівень 28, лекція 4
Недоступний
Препроцесор і макроси
Препроцесор і макроси
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ