Процес компіляції програми, яка складається з кількох файлів

Нам відомо, як проходить процес компіляції програми, яка складається лише з одного файлу коду. Якщо ж таких файлів є кілька, то необхідно не тільки скомпілювати кожен з них окремо, але й виконати кілька проміжних дій для того, щоб з’єднати скомпільований код у виконуваний файл. Тому для цього спочатку кожен файл з кодом, який входить до програми компілюється та перетворюється у спеціальний проміжний файл, який називається об’єктним файлом. Об’єктні файли містять скомпільований код та деяку службову інформацію потрібну для наступного етапу збірки програми. Часом про окремий файл вихідного коду, який входить до програми називають одиниця трансляції. Так кажуть, оскільки кожен файл окремо транслюється з мови програмування у машинний код.

Наступним етапом є етап компонування (linking, на жаргоні - лінкування), яке виконується програмою компонувальником (linker, лінкер). Ця програма читає кожен об’єктний файл, який входить до програми та поєднує їх у виконуваний файл програми. Це нетривіальна задача, оскільки:

  • Кожен з файлів, може містити виклики функцій чи використання типів, які є вихначені у одному з інших файлів. Наприклад, код функції знаходиться в одному файлі, а викликають його в іншому - і цю задачу необхідно розв’язати, щоб програма коректно працювала.
  • Може виявитися, що кілька файлів мають функцію визначену з одним і таким самим ім’ям і параметрами - у такому випадку ми маємо помилку, оскільки компонувальник не може вгадати замість нас, яку саме функцію викликати в тому чи іншому місці.
  • Врешті решт, виконуваний файл має свій власний формат, який диктує операційна система. Тож всі змінні, типи, функції мають бути не тільки перетворені у машинний код, але й за визначеними правилами записані всередину виконуваного файлу.
  • Крім того, до програми можуть підключатися спеціальні файли, які містять функції та типи готові до багаторазового використання у інших програмах - бібліотеки (library). Бібліотеки можуть вбудовуватись всередину програми під час компонування (статичні бібліотеки, static libraries), або ж завантажуватися у пам’ять разом з програмою під час її виконання (динамічні бібліотеки, dynamic libraries).

Створити виконуваний файл коректного формату, розв’язавши залежності у об’єктних файлах та бібліотеках, та знайти конфлікти у цих залежностях і є задачею компонувальника.

Приклад: функції для генерації випадкових чисел

У попредніх розділах ми оголосили кілька дуже зручних для нас функцій для генерації випадкових чисел. Давайте створимо окремий файл random-utils.cpp у який збережемо визначення цих функцій.

// Файл random-utils.cpp

#include <random>
#include <vector>

int generateRandomNumber(int min, int max)
{
    static std::random_device randomDevice;
    static std::mt19937 engine{randomDevice()};
    std::uniform_int_distribution<int> distribution(min, max);
    return distribution(engine);
}

std::vector<int> generateRandomSequence(int min, int max, int count)
{
    std::vector<int> rRandomSequence;
    for (int i = 0; i < count; ++i)
    {
        rRandomSequence.push_back(generateRandomNumber(min, max));
    }
    return rRandomSequence;
}

Тепер цей файл готовий до використання у інших наших програмах. Нам лише треба скомпілювати його з програмою, у якій ми використаємо виклики цих функцій. Зверніть увагу: файл не містить функцію main()! Якщо ми згадаємо матеріал одного з перших розділів, то така функція має бути лише одна на всю програму. А наша програма буде складатися з кількох файлів.

Тепер створимо головний файл програми: main.cpp. Цей файл міститиме функцію main(), також він буде викликати функцію generateRandomSequence() визначену у файлі random-utils.cpp.

// Файл main.cpp

#include <vector>
#include <iostream>

std::vector<int> generateRandomSequence(int min, int max, int count);

int main()
{
    std::vector<int> randomSequence = generateRandomSequence(1, 100, 10);
    for (int index = 0; index < randomSequence.size(); index++)
    {
        std::cout << randomSequence[index] << std::endl;
    }
}

Для того, щоб компіляція не викликала помилки (адже компілятор не знайде в main.cpp код функції generateRandomSequence()) ми оголосили сигнатуру функції generateRandomSequence() у цьому файлі. Таким чином ми повідомили компілятору, що така функція визначена, але перебуває в іншій одиниці трансляції. Для цього, достатньо продублювати назву функції разом зі всіма параметрами та типом повернення.

Компіляцію проведемо у три кроки. По-перше, скомпілюємо у об’єктний файл random-utils.cpp :

clang++ -c -o random-utils.o random-utils.cpp

Тут параметр -c означає, що треба створити об’єктний файл. Результат записуємо у файл random-utils.o.

Далі, скомпілюємо main.cpp:

clang++ -c -o main.o main.cpp

Врешті, щоб створити виконуваний файл, об’єднаємо об’єктні файли, які вийшли в результаті попередніх кроків:

clang++ main.o random-utils.o -o main.exe

Заголовні файли

Оскільки оголошувати сигнатури функцій не зручно, то користуються заголовковими файлами, які містять усі необхідні оголошення, щоб підключити функцій та типи з іншого файлу. Для того щоб створити заголовковий файл, помістимо оголошення функцій у файлі random-utils.h.

// Файл random-utils.h
#ifndef RANDOM_UTILS_H
#define RANDOM_UTILS_H

#include <vector>

int generateRandomNumber(int min, int max);
std::vector<int> generateRandomSequence(int min, int max, int count);

#endif // RANDOM_UTILS_H

Тепер ми можемо підключити цей файл в main.cpp.

#include <vector>
#include <iostream>
#include "random-utils.h"

int main()
{
    std::vector<int> randomSequence = generateRandomSequence(1, 100, 10);
    for (int index = 0; index < randomSequence.size(); index++)
    {
        std::cout << randomSequence[index] << std::endl;
    }
}

Тепер ми можемо скомпілювати main.cpp. Якщо нам необхідно підключити наші функції для генерації випадкових чисел та використати в іншому файлі коду, то можемо зробити це за допомогою директиви #include підключивши заголовний файл так само, як і в main.cpp. Давайте розглянемо цей заголовний файл і розберемося, як це працює.

Директива препроцесора #include

Першим етапом компіляції є обробка програми препроцесором. Препроцесор - це програма (зазвичай - частина компілятора C++), яка опрацьовує текст до того, як він буде перевірятися та транслюватися компілятором. Препроцесор маніпулює текстом програми і досволяє змінити його в залежності від обставин та налаштувань компіляції. Директива препроцесора #include дозволяє вставити вміст файлу який ми вказуємо у рядок, в якому стоїть ця директива. Таким чином під час компіляції усі оголошення з заголовкового файлу просто фізично додаються в текст програми, і вже далі результуючий текст компілюється.

Захист від повторного включення (include guards)

Якщо заголовковий сам містить директиви #include, то вони також замінюються на тест інших заголовкових файлів. Таким чином, така вкладеність може породжувати багато тексту, причому легко побачити, що з якогось моменту він може почати дублюватися (наприклад, якщо підключимо кілька заголовкових файлів, які в свою чергу підключають один і той же файл). Для того, щоб запобігти цьому використовують конструкцію з директив препорцесора, яка запобігає повторному включенню одного і того ж тексту заголовного файлу:

#ifndef /*макрос*/
#define /*макрос*/

// Вміст заголовного файлу

#endif

Тут весь вміст який розташований між #ifndef та #endif буде доданий до програми, тільки якщо заданий макрос не є визначений для препроцесора в даному файлі коду. Директива #define визначає цей макрос. Таким чином:

  • Якщо макрос не визначено: визначаємо цей макрос і включаємо текст заголовного файлу.
  • Якщо макрос вже визначено - препроцесор ігнорує весь текст між #ifndef та #endif, тож повторно він не зустрічатиметься у програмі.

Такий захист необхідно обов’язково робити для новостворених заголовних файлів. Назвою для макроса може бути, наприклад, назва заголовного файлу (як нашому прикладі), головне щоб макрос був достатньо унікальний і не конфліктував своїм ім’ям з іншими.

Як вказати шлях до заголовного файлу

Є два способи вказати шлях до заголовного файлу у директиві #include:

  • У лапках "": тоді препроцесор шукає файл відносно cpp-файлу, який містить цю директиву (тобто, наприклад, “random-utils.h” означає, що цей файл лежить в тій самій теці, що і файл вихідного коду, а якщо маємо “../includes/random-utils.h” - то на одну теку вище, всередині теки includes)
  • У гострих дужках <>: тоді файл знаходиться у теках, які задані налаштуваннями компіляції. Частина з цих тек підключені автоматично в системі, якій встановлено компілятор. Але під час компіляції можна додати будь-яку теку до цього списку, використавши опцію -I*шлях до теки*. Приклад такої команди: clang++ -c -I../randomutils/ -o main.o main.cpp.

Створення статичної бібліотеки

Оскільки ми збираємося використовувати функції для генерації випадкових чисел постійно, а компілювати їх не зручно, то ми можемо оформити їх у вигляді маленької статичної бібліотеки. Для цього, викличемо для об’єктного файлу команду, яка створить з нього статичну бібліотеку:

llvm-ar rc librandom-utils.a ./random-utils.o
  • llvm-ar - програма, яка створює бібліотеку
  • rc - параметри, r - переписати, якщо існує, c - створити нову бібліотеку
  • librandom-utils.a - назва бібліотеки
  • ./random-utils.o - об’єктний файл, який входить до бібліотеки (їх може бути кілька)

Тепер ми можемо підключити цю бібліотеку під час компіляції нашого прикладу:

clang++ main.cpp -L. -lrandom-utils -o main.exe

Крім знайомих вже нам параметрів

  • -L. - параметр -L задає шлях до теки, де лежать бібліотеки які підключають до програми. Таких параметірв може бути кілька - якщо потрібно вказати кілька шляхів. . (крапка) - одначає поточну теку, тобто кажемо що потрібно шукати бібліотеки, які ми підключаємо у поточній теці. Зверніть увагу, що коли ми задаємо цей параметр, то пробіл між -L та шляхом відсутній.
  • -lrandom-utils - підключаємо бібліотеку librandom-utils.a. Коли вказуємо ім’я бібліотеки - то маємо на увазі, що задане ім’я буде мати префікс lib на початку і завершуватиметься розширенням .a. Якщо ми не врахуємо цього, то компонувальник не зможе знайти бібліотеку і ми отримаємо помилку компонування, яка виглядатиме отак:
ld: cannot find -lrandom-utils
clang++: error: linker command failed with exit code 1 (use -v to see invocation)

Навіщо це потрібно?

  • Оскільки ми розуміємо, яким чином можна створити програму, яка складається з багатьох файлів вихідного коду, то можемо тепер розділити будь яку нашу програму на окремі логічно пов’язані частини. Це дасть могу, швидше знайти потрібний нам код у великій програмі, а не тримати все у одному великому файлі. Також це зручно для роботи у команді, коли інший розробник може працювати незалежно над цілою частиною коду, не редагуючи файл, у якому працює хтось інший. Якщо наші програми не обмежені одним файлом - то вони більше не обмежені складінстю та розміром.
  • У наступних розділах, ми розглянемо системи збірки програм, які досзволяють автоматизувати процес компіляції великих проектів. Така система, може зібрати у виконуваний файл проект, який складається з тисяч файлів вихідного коду буквально кількома командами. Оскільки ми розуміємо, як компілюються окремі файли, то зможемо розібратися, як працює така система.
  • Виділивши частину коду програми чи навіть створивши на її основі бібліотеку ми можемо повторно використати цей код у інших наших програмах.
  • Суперконсоль, якою ми користуємося, містить велику кількість готових до використання бібіліотек. Робота з мережею, різними форматами даних, графікою, готові частини на основі яких можна будувати складніші програми - усе це стає доступним. Нам лише потрібно підключити до свого коду інші бібліотеки (а як це робити ми тільки но розібрали на прикладі). У наступних розділах ми детальніше розглянемо деякі з доступних бібліотек, а також як вїх використовувати у наших програмах.