Раздельная компиляция проектов на С++

2016-04-18 07:51:17 1638

Если вы что-либо программируете, то рано или поздно ваши проекты становятся настолько большими, что хранить весь код в одном файле оказывается неудобно. В языке С++ при этом используется раздельная компиляция. В статье описывается маршрут сборки проекта, состоящего из нескольких файлов исходного кода и особенности использования заголовочных файлов. Вообще, статью я решил написать потому, что раздельную компиляцию очень часто (авторы книг по С++, в том числе) сравнивают с системой модулей (которые есть в других языках). Однако это некорректное сравнение. В следующей статье я опишу как в С++ реализовать более-менее нормальные модули с помощью пространств имен, но сначала надо разобраться с раздельной компиляцией.

1 Назначение раздельной компиляции:

Хранить весь свой исходный код можно в одном файле, однако обычно говорят о следующих причинах разделять его: логическое разделение кода на части упрощает восприятие проекта и поиск нужного фрагмента; проще отслеживать зависимости между файлами, чем между элементами программы в одном файле - это важно при документировании проекта; системы генерации документации по исходному коду (типа doxygen/JavaDoc) позволяют писать пометки к отдельным файлам [1]; разделение кода на файлы может значительно ускорить компиляцию, т.к. при внесении изменений достаточно проводить трансляцию только той части кода, которую затронули изменения. Есть множество других причин, которые я не считаю достаточно существенными - например, Страуструп [2] пишет что раздельная компиляция упрощает командную работу (если каждый программист работает в своем файле), но это не совсем актуально при использовании современных систем контроля версий (таких как git). Тем более устаревшими выглядят рассуждения о средах разработки, способных открывать в один момент только один файл с кодом.

2 Маршрут компиляции в С++:

Итак, в С++ мы можем легко добавить в проект несколько .cpp файлов (в IDE типа Visual Studio это делается с помощью нескольких кликов мышкой), написать в них какой-то код и скомпилировать. Однако, как функции одного файла могут использовать функции из другого? Как правильно разделить проект на файлы? - для ответа на вопросы нужно разобраться с процессом компиляции.

Файлы с кодом по-отдельности подаются на препроцессор, который обрабатывает такие директивы, как #include, #define и т.п. (про них мы поговорим позже). То, что формируется на выходе препроцессора называется единицей трансляции (для каждого .cpp файла будет сформирована своя единица трансляции), которая представляет собой код .cpp-файла, который мог быть чем-то дополнен (или наоборот - убрано лишнее). Транслятор преобразует единицы трансляции в объектные файлы, которые фактически содержат команды процессора, но не могут быть непосредственно выполнены, т.к. если один из них использует функцию из другого файла - то объектный файл содержит лишь объявление этой функции, а код функции размещен в другом объектном файле. Объединением этих файлов занимается линкер (компоновщик).

3 Использование функции из других файлов. Объявления и определения. Пример:

Допустим, программа должна обрабатывать список студентов и выбирать из них двоечников, а также - список преподавателей с целью составления расписания консультаций (для двоечников). Не вдаваясь в детали, мы можем выделить относительно слабо связанные части в программе - составление расписание преподавателей и обработка списка студентов, такие части целесообразно разместить в различных файлах исходного кода:

Пусть в файлах буду определены структуры успеваемости студента и расписания преподавателя, а также функции выполняющие соответствующие операции.

// create_expel_list.cpp enum Stuff { // преподаваемая дисциплина Literature, Mathematic }; struct Grade { // оценка Stuff stuff; enum { Excellent, Good, Acceptable, Bad } value; }; struct Student { string name; vector<Grade> grades; }; vector<Student> create_expel_list(const vector<Student>& students) { /* из набора студентов выбирает таких, что среди оценок имеют двойки */ } // create_timetable.cpp enum Stuff { // преподаваемая дисциплина Literature, Mathematic }; struct Prof { // преподаватель string name; Stuff stuff; }; struct Lesson { // занятие Stuff stuff; Prof prof; Time time; }; vector<Lesson> create_timetable(const vector<Prof>& profs, const vector<Student>& students) { /* принимает список двоечников и преподавателей каким-то образом планирует расписание занятий (набор уроков) */ }

Казалось бы, в файле main.cpp остается только использовать эти функции. Однако, в этом коде есть ряд недочетов:

  • структура Stuff объявлена в обоих файлах, т.к. она в них используется - без этого не сможет отработать транслятор (выдаст ошибку), ведь он обрабатывает единицы компиляции независимо друг от друга. Проблема возникнет если в одном из файлов мы добавим в структуру дополнительную дисциплину (например физику), а во втором забудем. Кроме того, в файле main.cpp нам также придется объявлять эту структуру (как и все остальные структуры), ведь именно там мы будем формировать список преподавателей;
  • функция create_timetable принимает на вход набор студентов, однако в файле отсутствует объявление структуры студента - ее нужно добавить точно также (с теми же проблемами), как структуру Stuff;
  • в файле main.cpp мы хотим вызывать функции из других файлов, однако еще при трансляции мы получим ошибки если не объявим эти функции в текущем файле;
//main.cpp /* объявления всех структур - студента, предмета, ... */ /* прототипы функций (лишь объявления, которые говорят транслятору, что где-то эти функции есть, чтобы он не ругался):*/ vector<Lesson> create_timetable(const vector<Prof>& profs, const vector<Student>& students); vector<Student> create_expel_list(const vector<Student>& students); // ввод списков студентов и преподавателей, // вызов функций create_expel_list и create_timetable

Теперь появляется новая проблема - если у какой-либо из функций мы изменим список параметров, а в прототипах main.cpp забудем - то ошибка не будет получена при трансляции, но при компоновке будет выявлено, что нужной функции в файлах нет. Ошибки линкера крайне неинформативны, в них очень сложно разобраться (линкер не может указать конкретную строку кода в файле с ошибкой, т.к. ошибки он выявляет лишь после соединения файлов в исполняемый файл). Для решения всех перечисленных проблем в языке С++ используется директива #include "имя файла", которая на этапе препроцессорной обработки заменяется на содержимое подключаемого файла, а также применяется ряд других директив.

4 Обработка директив препроцессора в С++

4.1 Директива #include

В языке С++ есть ряд директив, которые обрабатываются препроцессором. Файлы исходного кода в С++ разделяются на заголовочные (.h, .hpp) и файлы реализации (.cpp). В заголовочных файлах обычно описывают прототипы функций, определения структур, объявление данных (общих переменных), определение констант, а в файлах реализации размещают реализацию функций. Так делают потому, что один один заголовочный файл может быть включен в несколько разных единиц трансляции и если он будет содержать реализацию функции, то на этапе компоновки мы получим ошибку. Приведенный выше пример с использованием заголовочных файлов можно было бы переписать следующим образом:

// create_expel_list.h enum Stuff { // преподаваемая дисциплина Literature, Mathematic }; struct Grade { // оценка Stuff stuff; enum { Excellent, Good, Acceptable, Bad } value; }; struct Student { string name; vector<Grade> grades; }; vector<Student> create_expel_list(const vector<Student>& students); // create_expel_list.cpp #include "create_expel_list.h" vector<Student> create_expel_list(const vector<Student>& students) { /* из набора студентов выбирает таких, что среди оценок имеют двойки */ } // create_timetable.h #include "create_expel_list.h" struct Prof { // преподаватель string name; Stuff stuff; }; struct Lesson { // занятие Stuff stuff; Prof prof; Time time; }; vector<Lesson> create_timetable(const vector<Prof>& profs, const vector<Student>& students); // create_timetable.cpp #include "create_timetable.h" vector<Lesson> create_timetable(const vector<Prof>& profs, const vector<Student>& students) { /* принимает список двоечников и преподавателей каким-то образом планирует расписание занятий (набор уроков) */ } //main.cpp #include "create_timetable.h" // ввод списков студентов и преподавателей, // вызов функций create_expel_list и create_timetable

В результате применения директивы #include отпадает необходимость вручную в каждом файле, использующем какие-либо функции или структуры из другого прописывать объявления, мы выносим все объявления в заголовочный файл и подключаем сразу всех их одной строчкой. Теперь зависимости между файлами будут следующими:

4.2 Директивы #define и #ifndef. Стражи включения

В рассмотренном выше примере файл main.cpp включает в себя только create_timetable.h, хотя и использует структуры из create_expel_list.h - этого оказывается достаточно так как create_timetabe.h содержит включение create_expel_list.h (которое заменяется текстом файла). Библиотеки часто поставляются именно в виде набора заголовочных файлов и файлов реализации, однако программист, использующий библиотеки не должен знать как они устроены внутри и какие зависимости существуют между файлами. При этом, даже если в нашей программе в main.cpp подключить create_expel_list.h - мы получим ошибку при компиляции, т.к. одна и та же структура окажется объявленной дважды. Для решения проблемы применяются так называемые "стражи включения". Директива #define позволяет определить лексему (имя макроса) и строку, на которую она будет заменена препроцессором: #define PI 3.1415 Директива #ifndef используется для проверки того, был ли определен макрос с заданным именем:

// create_timetable.h #ifndef CREATE_TIMETABLE_H #define CREATE_TIMETABLE_H #include "create_expel_list.h" struct Prof { // преподаватель string name; Stuff stuff; }; struct Lesson { // занятие Stuff stuff; Prof prof; Time time; }; vector<Lesson> create_timetable(const vector<Prof>& profs, const vector<Student>& students); #endif

Фрагмент кода, помещенный между #ifndef и #endif будет пропущен компилятором если макрос с именем, переданным #ifndef уже был объявлен. Внутри этого фрагмента размещается соответствующее объявление макроса и всё содержимое заголовочного файла. В результате, при повторном включении файла (что очень часто бывает при наличии иерархических зависимостей между файлами) - его содержимое будет добавлено лишь один раз. Стражи включения должны быть прописаны в каждом заголовочном файле. Часто в качестве имени макроса стража включения выбирают имя соответствующего файла.

Дополнительная литература по теме:

  1. Работа с системой автоматической генерации кода doxygen на примере игры "Сапер".- URL: http://pro-prof.com/archives/887
  2. Страуструп Б. Язык программирования C++. 3-е издание. - М.: Бином, 1999. - 991 с.
  3. Раздельная компиляция в С++ (источник статьи).- URL: http://pro-prof.com/forums/topic/separate_compilation_in_cplusplus