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

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

В этой теме 0 ответов, 1 участник, последнее обновление  Васильев Владимир Сергеевич 10 мес., 2 нед. назад.

  • Автор
    Сообщения
  • #2697

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

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

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

    Хранить весь свой исходный код можно в одном файле, однако обычно говорят о следующих причинах разделять его:

    логическое разделение кода на части упрощает восприятие проекта и поиск нужного фрагмента;
    проще отслеживать зависимости между файлами, чем между элементами программы в одном файле — это важно при документировании проекта;
    системы генерации документации по исходному коду (типа doxygen/JavaDoc) позволяют писать пометки к отдельным файлам [1];
    разделение кода на файлы может значительно ускорить компиляцию, т.к. при внесении изменений достаточно проводить трансляцию только той части кода, которую затронули изменения.

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

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

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

    compilation_route_cpp

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

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

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

    compilation_cpp

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

    // 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 отпадает необходимость вручную в каждом файле, использующем какие-либо функции или структуры из другого прописывать объявления, мы выносим все объявления в заголовочный файл и подключаем сразу всех их одной строчкой. Теперь зависимости между файлами будут следующими:

    compilation_include_cpp

    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: https://pro-prof.com/archives/887
    2. Страуструп Б. Язык программирования C++. 3-е издание. — М.: Бином, 1999. — 991 с.

Для ответа в этой теме необходимо авторизоваться.