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 — простая дисциплина, которая экономит часы отладки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ