Различные статьи по Qt и c++

Разделение на единицы трансляции: компиляция и линковка, ODR

Введение

В этой статье разберём процесс сборки C/C++ программы системно: что представляет собой единица трансляции, как происходит раздельная компиляция и последующая линковка, и что такое One Definition Rule (ODR), нарушение которого — частый источник трудно диагностируемых ошибок.

Концепция

Единица трансляции — это один .cpp-файл вместе со всем содержимым, которое попало в него через директивы #include после полной обработки препроцессором — каждая единица трансляции компилируется НЕЗАВИСИМО от других в отдельный объектный файл (.o/.obj), и компилятор на этапе компиляции конкретной единицы трансляции не имеет доступа к содержимому других единиц трансляции проекта, кроме того, что явно объявлено (но не обязательно определено) через включённые заголовочные файлы. Линковщик (linker) объединяет все отдельно скомпилированные объектные файлы проекта (а также объектные файлы используемых библиотек) в единый исполняемый файл, разрешая ссылки между ними — именно на этом этапе, а не на этапе компиляции отдельной единицы трансляции, обнаруживаются ошибки типа «функция объявлена, но определение нигде не найдено». One Definition Rule — правило C++, требующее, что любая сущность (функция, не-inline переменная, класс) должна иметь РОВНО ОДНО определение во всей программе (с учётом всех единиц трансляции вместе), и нарушение этого правила (например, определение одной и той же не-inline функции в двух разных .cpp-файлах) является неопределённым поведением, иногда вовсе не обнаруживаемым на этапе линковки.

Пример кода

// math_utils.h — объявление, видимое во ВСЕХ единицах трансляции, включающих этот файл
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b); // ОБЪЯВЛЕНИЕ — компилятор знает сигнатуру, реальный код где-то ОТДЕЛЬНО

#endif
// math_utils.cpp — ОДНА единица трансляции с ОПРЕДЕЛЕНИЕМ функции add
#include "math_utils.h"

int add(int a, int b) // РЕАЛЬНОЕ определение — существует РОВНО ОДИН РАЗ во всём проекте
{
    return a + b;
}
// main.cpp — ДРУГАЯ единица трансляции, использующая add() через объявление из заголовка
#include "math_utils.h" // только ОБЪЯВЛЕНИЕ — компилятор этой единицы трансляции НЕ видит реализацию add()
#include <iostream>

int main()
{
    std::cout << add(3, 4) << std::endl; // компилируется успешно — сигнатура известна из заголовка
    // Реальное соединение вызова add() здесь с его ОПРЕДЕЛЕНИЕМ в math_utils.cpp
    // происходит на этапе ЛИНКОВКИ, а не компиляции этого конкретного файла
    return 0;
}
// Нарушение ODR — определение одной и той же функции в ДВУХ разных единицах трансляции
// file1.cpp
int calculate() { return 1; }

// file2.cpp
int calculate() { return 2; } // ОШИБКА ЛИНКОВКИ (или, в некоторых случаях, неопределённое поведение) —
                                // ОДНА И ТА ЖЕ функция calculate определена ДВАЖДЫ во всём проекте

Пояснения к коду

Разделение на math_utils.h (объявление) и math_utils.cpp (определение) демонстрирует фундаментальную структуру, лежащую в основе раздельной компиляции — main.cpp, включающий лишь заголовочный файл, успешно компилируется без какого-либо доступа к реальному коду add(), поскольку компилятору достаточно знать сигнатуру функции (что она существует, какие принимает параметры и что возвращает) для проверки корректности вызова на этапе компиляции, оставляя реальное «соединение» этого вызова с конкретным определением функции линковщику, выполняющему свою работу уже после успешной, независимой компиляции каждой отдельной единицы трансляции. Пример нарушения ODR с двумя определениями calculate() в file1.cpp/file2.cpp показывает классический случай, обнаруживаемый линковщиком (как ошибка «multiple definition») — поскольку линковщик должен выбрать ровно ОДНО определение каждой используемой сущности при объединении всех объектных файлов в единый исполняемый файл, наличие нескольких, конфликтующих определений делает эту задачу невыполнимой однозначным, корректным образом.

Подводные камни

  • Определение (а не только объявление) обычной, не-inline функции непосредственно в заголовочном файле, который затем включается в несколько разных .cpp-файлов проекта — это прямое нарушение ODR, поскольку каждая единица трансляции, включившая такой заголовок, получит собственную копию полного определения функции, и при попытке линковки всех этих единиц трансляции в единый исполняемый файл линковщик обнаружит множественные, конфликтующие определения одной и той же функции (если только функция не объявлена явно inline, статья ниже о связанных тонкостях, специально предназначенном именно для разрешения этого случая).
  • Определение глобальной (не-inline, не-static) переменной непосредственно в заголовочном файле — аналогичная проблема ODR применима и к переменным, и наивная попытка «удобно» определить общую, разделяемую между несколькими файлами переменную прямо в заголовке (а не через паттерн extern-объявление в заголовке плюс единственное определение в одном конкретном .cpp-файле) приведёт к точно такой же ошибке линковки о множественном определении при включении этого заголовка в несколько единиц трансляции.
  • Несогласованность объявления функции в заголовочном файле с её реальным определением (отличающаяся сигнатура, типы параметров) — компилятор каждой отдельной единицы трансляции проверяет вызовы функции только против доступного ему объявления, и если реальное определение (в другом файле) имеет несовместимую сигнатуру, эта несогласованность обнаруживается линковщиком как ошибка «функция не найдена» (поскольку линковщик ищет точное соответствие сигнатуры, закодированной особым образом в имени символа через механизм, известный как mangling имён в C++), что может на первый взгляд выглядеть странно для разработчика, ожидавшего обычную ошибку компиляции, а не линковки.
  • Понимание inline-функций как исключения из обычного правила ODR, позволяющего определить функцию непосредственно в заголовочном файле без нарушения правила (при условии, что все определения этой inline-функции во всех единицах трансляции идентичны) — это специальное, явное исключение из обычного запрета на определение функций в заголовках именно потому, что inline исторически (и в современном понимании этого ключевого слова, отличающемся от его исходного, буквального смысла «подстановки кода вместо вызова») сигнализирует компилятору и линковщику об особом, разрешённом случае множественного, но идентичного определения в разных единицах трансляции, и непонимание этой тонкости может привести к путанице между случаями, где определение в заголовке допустимо (с inline, или для шаблонов, по сходным причинам), и где оно категорически недопустимо (обычные, не-inline функции и переменные).