Код, который спокойно собирается GCC на Linux, при переезде на Windows встречает MSVC со своим взглядом на язык, а на macOS ещё и Clang от Apple. Первая реакция команды знакома многим: добавить пару ifdef, пусть компилятор выберет нужную ветку. Дёшево, быстро, работает. Через три года проект держит на себе сотни таких пар, половина веток ни разу не компилировалась на машине автора, а поиск по репозиторию находит условные директивы в файлах, о которых никто не подозревал. Ниже разбор того, какие различия компиляторов и платформ настоящие, чем опасна унификация кода через препроцессор и что взамен макросов предлагают сам язык и CMake.
Как ifdef обрастают в платформенном слое и почему макросы перестают спасать
Начало всегда невинное. Один общий файл, две ветки, чуть-чуть платформенной разницы:
#ifdef _WIN32
#include <windows.h>
#else
#include <pthread.h>
#endif
Через год рядом стоят проверки компилятора, версии стандарта, разрядности и конкретной сборки операционной системы:
#if defined(_MSC_VER) && !defined(__clang__)
#define FORCEINLINE __forceinline
#elif defined(__GNUC__)
#define FORCEINLINE inline __attribute__((always_inline))
#endif
В зрелых проектах конфигурационный заголовок распухает до сотен строк, и каждая строка когда-то казалась кому-то полезной. Дальше начинаются свойства, о которых авторы не думали в момент вставки. Макрос это текстовая замена, которая выполняется до компиляции, компилятор не видит его как сущность языка. У него нет области видимости, он не входит в таблицу символов, линтер и переходы по определениям его не замечают. Порядок включения заголовков меняет поведение файла, а зависимости транзитивны: windows.h объявляет макросы min и max, и вызов std::min перестаёт компилироваться, откуда и растёт обходной макрос NOMINMAX. Системные заголовки Linux объявляют major и minor, и класс с одноимёнными методами ломается на ровном месте. Пространств имён у препроцессора нет, из другого модуля макрос не отключить, только переопределить.
Размеры типов и кодировки которые действительно различаются между платформами
Унификация была бы дешёвой, если бы различия между платформами были выдумкой. Различия настоящие, и лучше знать их заранее, а не по трассировке падения.
Разрядная модель. Windows в 64-разрядной версии использует LLP64, там long занимает 4 байта при 8-байтовом указателе. Linux и macOS используют LP64, там long занимает 8 байт. Тип int64_t прячет различие, но оно всплывает в форматировании вывода, потому что int64_t разворачивается в long long у MSVC и в long у GCC, и искажённые имена функций различаются.
Знаковость char. На x86 char по умолчанию знаковый у GCC и Clang, на ARM он по умолчанию беззнаковый. Сравнение байта со значением выше 127 даёт разные результаты на разных процессорах, и баг ловится только на целевой машине. Лечится флагами -fsigned-char у GCC и Clang или /J у MSVC, но осознанно и явно.
wchar_t занимает 2 байта на Windows, где хранится UTF-16, и 4 байта на Linux, где хранится UTF-32. Стандартные фиксированные типы char16_t и char32_t из C++11 и char8_t из C++20 снимают неоднозначность для нового кода.
long double это 80-битный формат на x86 под Linux и обычный 64-битный у MSVC и на ARM. Форматированный вывод с %zu появился в MSVC только с Visual Studio 2015, и код, рассчитанный на старые версии, приходилось городить с условной компиляцией.
Отдельная глава истории, двухфазный поиск имён в шаблонах. MSVC долгие годы пропускал часть проверок до инстанцирования, и код, собиравшийся десятилетиями, посыпался при включении строгого режима. Флаг /permissive-, появившийся в Visual Studio 2017, привёл реальное поведение к стандарту, и по ходу переезда команды вскрывались сотни неточностей, которые GCC и Clang ловили всегда. Классический пример, вызов члена зависимого базового класса без this:
template <typename T>
struct Base {
void ping() {}
};
template <typename T>
struct Derived : Base<T> {
void call() { ping(); }
};
Стандарт требует разбирать имена при определении шаблона, GCC и Clang отказывают сразу, а старый MSVC откладывал поиск до инстанцирования и прощал. Включение строгого режима превратило прощённое в ошибку, и код, годами собиравшийся на Windows, потребовал расставить this-> в сотнях мест. Даже на свежем GCC 16.2 от августа 2026 года и Clang 23.1 от июля 2026 года различия никуда не делись, они часть платформы, а не незрелость инструментов.
Когда условная компиляция ломает сборку через непроверяемые ветки и ODR
Самый дорогой дефект макросов связан с правилом одного определения. Если один заголовок компилируется в разных единицах трансляции с разным состоянием макросов, компилятор честно собирает два разных класса под одним именем:
class Frame {
public:
std::size_t size() const;
#ifdef FRAME_TRACE
void trace() const;
#endif
private:
std::vector<char> buf_;
};
Там, где FRAME_TRACE объявлен, у класса появляется метод trace и меняется раскладка, а вместе с ней и результат sizeof. Линкер ничего не подозревает, обе версии попадают в один бинарник, и выбор зависит от порядка файлов. Это неопределённое поведение, которое проявляется падением раз в неделю и только в релизной сборке, где NDEBUG отключил ещё и проверочные вызовы assert вместе с их побочными эффектами.
Вторая беда, гниение веток. Разработчик на Linux правит общий файл, соседняя ветка под Windows на его машине не компилируется в принципе, и опечатка в ней живёт годами, пока её не встретит сборка клиента. Механизм ifdef не даёт компилятору проверить выбранную ветку на чужой платформе, он её просто вырезает из текста. Каждый ifdef в общем файле это кусок кода, который какая-то часть команды никогда не видит и не собирает.
Третья беда, непрозрачность. Поиск по символам не находит макрос, граф вызовов его не показывает, отладчик показывает подставленный текст вместо источника. Сотня условных директив делает кодовую базу полупрозрачной для любых инструментов анализа, от статических анализаторов до обычного перехода к определению.
Генераторные выражения CMake вместо препроцессора в файлах сборки
Различия между платформами честнее держать там, где они и живут, в системе сборки. CMake умеет выбирать флаги по компилятору без ручных веток в командах:
target_compile_options(app PRIVATE
$<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic>
)
Выражение вычисляется на этапе генерации, после того как выбор компилятора уже сделан, и разворачивается в строку флагов конкретной платформы. Строить можно каскады: проверка версии через $<VERSION_GREATER:$<CXX_COMPILER_VERSION>,13>, условия через $<BOOL:...> и $<IF:...>. Ловушки известны тем, кто их уже топил: проверка if(UNIX) истинна и на macOS, для чистого Linux пишут UNIX AND NOT APPLE, а проверка MSVC не ловит clang-cl, который притворяется MSVC.
Стандарт языка выбирается в CMake тоже парой строк, без платформенных веток:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Эти строки разворачиваются в -std=c++20 у GCC и Clang и в /std:c++20 у MSVC, и диалект языка на трёх компиляторах совпадает без всякого участия препроцессора.
Тот же принцип работает для выбора исходников. Вместо ifdef внутри одного файла правильнее держать по одному файлу на платформу и выбирать нужный файлом сборки:
if(WIN32)
target_sources(app PRIVATE src/fs_win.cpp)
else()
target_sources(app PRIVATE src/fs_posix.cpp)
endif()
Общий заголовок остаётся без единой условной директивы, каждый файл компилируется целиком, линтер видит весь код, а различие платформ локализовано в паре строк CMakeLists.txt, которые видны при ревью с первого взгляда.
Механизмы современного C++ заменяющие макросы в ежедневном коде
Часть макросов вообще не нужна, потому что язык закрыл их задачи сам. Числовые константы живут как inline constexpr, с типом, областью видимости и видимостью для инструментов. Ветвление внутри шаблонов досталось if constexpr из C++17, и порядок байтов теперь обрабатывается без единого ifdef:
inline std::uint32_t to_little_endian(std::uint32_t v) {
if constexpr (std::endian::native == std::endian::little) {
return v;
} else {
return std::byteswap(v);
}
}
Здесь std::endian из C++20, std::byteswap из C++23, и в этом весь принцип: обе ветки видимы компилятору на всех платформах, синтаксис проверяется везде, а ненужная ветка отбрасывается целиком на этапе компиляции. Ошибку в отброшенной ветке заметит любая машина, не только та, для которой ветка предназначена.
Необязательные заголовки проверяются директивой __has_include из C++17:
#if __has_include(<version>)
#include <version>
#endif
Информация о месте в коде, которую годами собирали из __FILE__, __LINE__, __FUNCSIG__ и __PRETTY_FUNCTION__, теперь даёт std::source_location из C++20, одинаковый на всех компиляторах. Пути собирает std::filesystem::path, который сам знает про разделители и кодировки операционной системы, без ручных склеек со слэшами.
Препроцессору остаются его честные обязанности: директивы #include, защита от повторного включения, __has_include и редкая тонкая проверка компилятора там, где стандарт ещё не дорос. Список короткий, и именно такой длины ему и положено быть.
Границы бинарной совместимости библиотек и переход через extern C
Различия компиляторов не единственная боль, есть ещё рантаймы. libstdc++ и libc++ искажают имена по-разному, и линковка одного процесса смешанным набором это лотерея с крэшем на старте. В libstdc++ с версии GCC 5 живёт двойной ABI, макрос _GLIBCXX_USE_CXX11_ABI переключает искажение имён для std::string и std::list, и собрать библиотеку с одним значением, а приложение с другим, значит получить несовместимые символы. Microsoft держит бинарную совместимость инструментальных наборов, начиная с Visual Studio 2015, но смешение рантаймов /MD и /MT по-прежнему запрещено, а экспорт стандартных типов из DLL рождает предупреждение C4251, у которого есть веские причины.
Для плагинов, модулей и всего, что переживает смену компилятора, рабочей границей остаётся C ABI:
extern "C" {
typedef struct engine_t* engine_handle;
engine_handle engine_create(void);
void engine_destroy(engine_handle h);
int engine_run(engine_handle h, const char* config_path);
}
Исключения через границу не ходят, память выделяет и освобождает одна сторона, типы C++ прячутся за непрозрачным указателем. Такой интерфейс переживает смену компилятора, стандартной библиотеки и даже языка у соседей по процессу.
Практический план чистки платформенного слоя с проверкой на трёх ОС
Чистка зрелого проекта с сотнями условных директив идёт по проверенному кругу:
- выписать все ifdef из исходников и разложить их на три стопки, зависимость от платформы, от компилятора, от версии стандарта;
- код первой стопки перенести в отдельные файлы реализации по платформе, а в общем заголовке оставить чистый интерфейс без единой условной директивы;
- константы и псевдонимы перевести на inline constexpr, ветвление в шаблонах на if constexpr, необязательные заголовки на __has_include;
- в файлах сборки заменить вложенные ручные ветки на генераторные выражения и единые списки источников;
- поднять CI с матрицей из Linux, Windows и macOS, чтобы каждая правка общего кода компилировалась всеми тремя компиляторами;
- закрепить в правилах проекта норму, по которой новая условная директива появляется только после письменного объяснения, почему без неё нельзя обойтись.
После первого круга обычно вскрывается забавная деталь: значительная часть ifdef обслуживала различия, которых давно нет. Остаток делится на честный платформенный слой, который живёт в своих файлах и собирается только на своей платформе, и на общую логику, которую видят все компиляторы. Итог формулируется одной строкой: код общий там, где он действительно общий, и честно разный там, где он разный. Унификация через препроцессор экономит час сегодня и забирает годы потом, а единая кодовая база строится не на ifdef, а на дисциплине границ: платформа в платформенном слое, компилятор в генераторных выражениях, версия стандарта в флагах сборки. Различия компиляторов неустранимы, локализовать их можно полностью.