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 — простая дисциплина, которая экономит часы отладки.

1
Задача
C++ SELF, 28 уровень, 1 лекция
Недоступна
Формула на табло
Формула на табло
1
Задача
C++ SELF, 28 уровень, 1 лекция
Недоступна
Два квадрата
Два квадрата
1
Задача
C++ SELF, 28 уровень, 1 лекция
Недоступна
Лог без скобок
Лог без скобок
1
Задача
C++ SELF, 28 уровень, 1 лекция
Недоступна
Имя после undef
Имя после undef
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ