Определения препроцессора (preprocessor definitions) часто используются в С++ проектах для условной компиляции выбранных участков кода, например, платформозависимых и т.п. В этой статье будут рассмотрены, видимо, единственные (но крайне сложные в отладке) грабли, на которые можно наступить при проставлении #define-ов через флаги компилятора.
В качестве примера возьмем систему сборки CMake, хотя те же действия можно совершить в любом другом ее популярном аналоге.
Введение и описание проблемы
Некоторые платформозависимые определения, вроде проверки на Windows/Linux, проставляются компилятором, поэтому их можно использовать без дополнительной помощи систем сборки. Однако, многие другие проверки, такие, как наличие #include-файлов, наличие примитивных типов, наличие в системе требуемых библиотек или даже простое определение битности системы, значительно проще делать «снаружи», впоследствии передавая требуемые определения через флаги компилятора. Кроме того, можно просто передать дополнительные определения:
g++ myfile.cpp -D MYLIB_FOUND -D IOS_MIN_VERSION=6.1
#ifdef MYLIB_FOUND
#include <mylib/mylib.h>
void DoStuff() {
mylib::DoStuff();
}
#else
void DoStuff() {
// own implementation
}
#endif
В CMake проставление #define-ов через компилятор делается с помощью add_definitions, которая добавляет флаги компилятора ко всему текущему проекту и его подпроектам, как и практически все команды CMake:
add_definitions(-DMYLIB_FOUND -DIOS_MIN_VERSION=6.1)
Казалось бы, что никаких проблем тут быть не может. Однако, при невнимательности можно допустить серьезную ошибку:
Если некоторый #define, проставляемый компилятором для проекта А, проверяется в заголовочном файле того же проекта А, то при #include этого заголовочного файла из другого проекта B, не являющегося подпроектом А, этот #define не будет проставлен.
Пример 1 (простой)
Рабочий пример описанной ошибки можно посмотреть на github/add_definitions/wrong. Под спойлером, на всякий случай, продублированы значимые куски кода:
project(wrong)
add_subdirectory(lib)
add_subdirectory(exe)
project(lib)
add_definitions(-DMYFLAG=1)
add_library(lib lib.h lib.cpp)
project(exe)
add_executable(exe exe.cpp)
target_link_libraries(exe lib)
// lib.h
static void foo() {
#ifdef MYFLAG
std::cout << "foo: all good!" << std::endl;
#else
std::cout << "foo: you're screwed :(" << std::endl;
#endif
}
void bar(); // implementation in lib.cpp
// lib.cpp
#include "lib.h"
void bar() {
#ifdef MYFLAG
std::cout << "bar: all good!" << std::endl;
#else
std::cout << "bar: you're screwed :(" << std::endl;
#endif
}
// exe.cpp
#include "lib/lib.h"
int main() {
foo();
bar();
}
Запуск `exe` выведет:
foo: you're screwed :(
bar: all good!
Этот пример очень простой: в нем даже есть какой-то вывод в консоль. В реальности, такая ошибка может встретиться при подключении достаточно навороченных библиотек вроде Intel Threading Building Blocks, где часть низкоуровневых параметров действительно можно передать через препроцессорные определения, причем они используются и в заголовочных файлах. Поиск удивительных ошибок в таких условиях крайне болезненный и долгий, особенно, когда этот нюанс add_definitions ранее не встречался.
Пример 2 (C++ hell)
Для наглядности, вместо двух проектов будем использовать один, вместо add_definitions будет обыкновенный #define внутри кода, а от CMake вообще откажемся. Этот пример — еще одна сильно упрощенная, но реальная ситуация, предоставляющая интерес, в том числе, с точки зрения общих знаний С++.
Запускаемый код можно посмотреть на github/add_definitions/cpphell. Как и в предыдущем примере, значимые участки кода под спойлером:
// a.h
class A {
public:
A(); // implementation in a.cpp with DANGER defined
~A(); // for illustrational purposes
#ifdef DANGER
std::vector<int> just_a_vector_;
std::string just_a_string_;
#endif // DANGER
};
// a.cpp
#define DANGER // let's have a situation
#include "a.h"
A::A() {
std::cout << "sizeof(A) in A constructor = " << sizeof(A) << std::endl;
}
A::~A() {
std::cout << "sizeof(A) in A destructor = " << sizeof(A) << std::endl;
std::cout << "Segmentation fault incoming..." << std::endl;
}
// main.cpp
#include "a.h" // DANGER will not be defined from here
void just_segfault() {
A a;
// segmentation fault on 'a' destructor
}
void verbose_segfault() {
A *a = new A();
delete a;
}
int main(int argc, char **argv) {
std::cout << "sizeof(A) in main.cpp = " << sizeof(A) << std::endl;
// verbose_segfault(); // uncomment this
just_segfault();
std::cout << "This line won't be printed" << std::endl;
}
Ошибка прекрасная. Один файл (a.cpp) видит скрытые под #ifdef-ом члены класса, а другой (main.cpp) — нет. Для них классы становятся разного размера, что влечет проблемы с управлением памятью, в частности, Segmentation Fault:
g++ main.cpp a.cpp -o main.out && ./main.out
sizeof(A) in main.cpp = 1
sizeof(A) in A constructor = 32
sizeof(A) in A destructor = 32
Segmentation fault incoming...
Segmentation fault (core dumped)
Если раскомментировать в main.cpp вызов verbose_segfault(), то в конце выведется:
*** Error in `./main.out': free(): invalid next size (fast): 0x000000000149f010 ***
======= Backtrace: =========
...
======= Memory map: ========
...
После некоторого количества экспериментов выяснилось, что если вместо STL классов использовать любое количество примитивных типов в полях класса А, то падения не наблюдается, поскольку для деструкторы для них не вызываются. Кроме того, если вставить одинокую std::string (на 64-bit Arch Linux и GCC 4.9.2 sizeof(std::string) == 8), то падения нет, а если уже две — то есть. Полагаю, дело в выравнивании, но надеюсь, что в комментариях смогут подробно разъяснить, что же на самом деле происходит.
Возможные решения
Не использовать «внешние» определения в заголовочных файлах
Если это возможно, то это самый простой вариант. К сожалению, иногда под #ifdef-ами стоят различные платформо- и компиляторозависимые сигнатуры функций, а некоторые библиотеки вообще состоят только из заголовочных файлов.
Использовать add_definitions в корневом CMakeLists.txt
Это, конечно, решает проблему «забытых» переданных флагов для конкретного проекта, но последствия следующие:
- Параметры командной строки компилятора будут включать все флаги для всех проектов, включая те проекты, которым эти флаги не нужны — сложность в отладке, например, через make VERBOSE=1, когда хочется понять, с чем же этот компилятор на конкретном файле себя вызывает.
- Этот проект нельзя будет «встроить» как подпроект в другой проект, потому что тогда будет наблюдаться точно такая же проблема. Стоит отметить, что в CMake процесс встраивания проекта, чаще всего, совершенно безболезненный, и такой возможностью часто не стоит пренебрегать.
Использовать конфигурационные заголовочные файлы и configure_file
CMake предоставляет возможность создания конфигурационных заголовочных файлов с помощью configure_file. В репозитории хранятся заранее заготовленные шаблоны, из которых, на момент сборки проекта CMake-ом, генерируются сами конфигурационные файлы. Сгенерированные файлы #include-тся в требуемых заголовочных файлах проекта.
При использовании configure_file следует помнить, что теперь проставления препроцессорных определений «снаружи» конкретного проекта через add_definitions работать не будет. Конечно, можно сделать особенный конфигурационный файл, который проставляет флаги только если они еще не были проставлены (#ifndef), но это внесет еще больше путаницы.
Заключение
Показанные ошибки и варианты решений, конечно, подходят не только для CMake-проектов, а и для проектов с другими системами сборки.
Надеюсь, эта статья однажды сэкономит кому-то кучу времени при отладке совершенно магических ошибок в С++ проектах, в которых есть проставление препроцессорных определений в заголовочных файлах.
Автор: dreamzor