Почему определение встроенной глобальной функции в 2 разных файлах cpp вызывает волшебный результат?
Предположим, что у меня есть два файла .cpp file1.cpp
и file2.cpp
:
// file1.cpp
#include <iostream>
inline void foo()
{
std::cout << "f1\n";
}
void f1()
{
foo();
}
и
// file2.cpp
#include <iostream>
inline void foo()
{
std::cout << "f2\n";
}
void f2()
{
foo();
}
И в main.cpp
я указал вперед f1()
и f2()
:
void f1();
void f2();
int main()
{
f1();
f2();
}
Результат (не зависит от сборки, тот же результат для сборки отладки/выпуска):
f1
f1
Whoa: Компилятор каким-то образом выбирает только определение из file1.cpp
и использует его также в f2()
. Каково точное объяснение этого поведения?
Обратите внимание, что изменение inline
до static
является решением этой проблемы. Ввод встроенного определения внутри неназванного пространства имен также решает проблему, и программа печатает:
f1
f2
Ответы
Ответ 1
Это поведение undefined, потому что два определения одной и той же встроенной функции с внешней связью прерывают требование С++ для объектов, которые могут быть определены в нескольких местах, известных как одно правило определения:
3.2 Одно правило определения
...
- Может быть более одного определения типа класса (раздел 9), типа перечисления (7.2), встроенной функции с внешней связью (7.1.2), шаблона класса (раздел 14), [...] в программа предусматривает, что каждое определение появляется в другой единицы перевода, и при условии, что определения удовлетворяют следующим требованиям. Учитывая такой объект с именем D, определенный более чем в одной единицы перевода, тогда
6.1 каждое определение D должно состоять из одной и той же последовательности токенов; [...]
Это не проблема с функциями static
, потому что одно правило определения не применяется к ним: С++ считает, что функции static
, определенные в разных единицах перевода, не зависят друг от друга.
Ответ 2
Компилятор может предположить, что все определения одной и той же функции inline
идентичны во всех единицах трансляции, потому что стандарт говорит об этом. Поэтому он может выбрать любое определение, которое он хочет. В вашем случае это случилось с f1
.
Обратите внимание, что вы не можете полагаться на компилятор, который всегда выбирает одно и то же определение, нарушая вышеупомянутое правило, делает программу плохо сформированной. Компилятор также может диагностировать это и выходить из системы.
Если функция static
или в анонимном пространстве имен, у вас есть две различные функции, называемые foo
, и компилятор должен выбрать один из нужного файла.
Соответствующий стандарт для справки:
Встроенная функция должна быть определена в каждой единицы перевода, в которой она используется , и должна иметь точно одно и то же определение в каждом случае (3.2). [...]
7.1.2/4 в N4141, подчеркните мою.
Ответ 3
Как отмечали другие, компиляторы соответствуют стандарту С++, поскольку правило Одно определение утверждает, что у вас должно быть только одно определение функции, кроме случаев, когда функция является встроенной, тогда определения должны быть одинаковыми.
На практике происходит то, что функция помечена как встроенная, а на этапе компоновки, если она запускается во множество определений встроенного флагманского токена, компоновщик молча отбрасывает все, кроме одного. Если он использует несколько определений маркера, не помеченного в строке, он генерирует ошибку.
Это свойство называется inline
, потому что до LTO (оптимизация времени ссылки), беря тело функции и "вставляя" ее на сайт вызова, требуется, чтобы компилятор имел тело функции. inline
функции могут быть помещены в файлы заголовков, и каждый файл cpp может видеть тело и "встроить" код в сайт вызова.
Это не означает, что код действительно будет встроен; скорее, это облегчает компиляторам встроить его.
Однако я не знаю компилятора, который проверяет идентичность определений перед отбрасыванием дубликатов. Сюда входят компиляторы, которые в противном случае проверяют определения функциональных тел для того, чтобы быть идентичными, например, сгибание MSVC COMDAT. Это меня огорчает, потому что это чересчур сложный набор ошибок.
Правильный способ решения проблемы состоит в том, чтобы поместить функцию в анонимное пространство имен. В общем, вы должны рассмотреть возможность размещения всего в исходном файле в анонимном пространстве имен.
Другой действительно неприятный пример:
// A.cpp
struct Helper {
std::vector<int> foo;
Helper() {
foo.reserve(100);
}
};
// B.cpp
struct Helper {
double x, y;
Helper():x(0),y(0) {}
};
Методы определенные в теле класса, неявно встроены. Правило ODR применяется. Здесь мы имеем два разных Helper::Helper()
, как встроенных, так и разных.
Размеры двух классов различаются. В одном случае мы инициализируем два sizeof(double)
с помощью 0
(поскольку в большинстве ситуаций нулевой float равен нулю байтам).
В другом случае мы сначала инициализируем три sizeof(void*)
с нулем, затем вызываем .reserve(100)
на эти байты, интерпретируя их как вектор.
При времени ссылки одна из этих двух реализаций отбрасывается и используется другой. Что еще, которое отбрасывается, скорее всего, будет довольно детерминированным в полной сборке. В частичной сборке он может изменить порядок.
Итак, теперь у вас есть код, который может создавать и работать "отлично" в полной сборке, но частичная сборка вызывает повреждение памяти. И изменение порядка файлов в make файлах может привести к повреждению памяти или даже к изменению файлов заказа, или обновлению вашего компилятора и т.д.
Если оба файла cpp имели блок namespace {}
, содержащий все, кроме экспортируемого вами материала (который может использовать имена с полным именем имен), этого не может быть.
Я поймал именно эту ошибку в производстве несколько раз. Учитывая, насколько он утончен, я не знаю, сколько раз он проскользнул, ожидая момента, когда он набросится.
Ответ 4
ТОЧКА РАЗРЕШЕНИЯ:
Хотя ответ, основанный на встроенном правиле С++, верен, он применяется только в том случае, если оба источника скомпилированы вместе. Если они компилируются отдельно, то, как заметил один комментатор, каждый результирующий объектный файл будет содержать свой собственный "foo()" . ОДНАКО: Если эти два объектных файла затем связаны друг с другом, то, поскольку оба "foo()" - s нестатические, в экспортированной таблице символов обоих объектных файлов отображается имя "foo()" ; то компоновщик должен объединить две записи в таблице, поэтому все внутренние вызовы повторно привязаны к одной из двух подпрограмм (по-видимому, одна в первом обработанном объектном файле, так как она уже привязана [то есть, компоновщик будет обрабатывать вторую запись как "extern", независимо от привязки]).