Почему определение встроенной глобальной функции в 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

Ого: Компилятор как-то выбирает только определение из file1.cpp и использует его также в f2(), Каково точное объяснение этого поведения?

Обратите внимание, что меняется inline в static является решением этой проблемы. Помещение встроенного определения в безымянное пространство имен также решает проблему, и программа печатает:

f1
f2

30

Решение

Это неопределенное поведение, потому что два определения одной и той же встроенной функции с внешней связью нарушают требование C ++ для объектов, которые могут быть определены в нескольких местах, известных как Одно Правило Определения:

3.2 Одно правило определения

  1. В программе может быть несколько определений типа класса (раздел 9), типа перечисления (7.2), встроенной функции с внешней связью (7.1.2), шаблона класса (раздел 14), […] при условии, что каждое определение отображается в отдельной единице перевода, и при условии, что определения удовлетворяют следующим требованиям. Если такой объект с именем D определен более чем в одной единице перевода, то

6.1 каждое определение D должно состоять из одинаковой последовательности токенов; […]

Это не проблема с static функции, потому что одно правило определения к ним не относится: C ++ считает static функции, определенные в разных единицах перевода, должны быть независимыми друг от друга.

41

Другие решения

Компилятор может предположить, что все определения одинаковы inline функции идентичны во всех единицах перевода, потому что стандарт говорит так. Так что он может выбрать любое определение, какое захочет. В вашем случае это случилось с f1,

Обратите внимание, что вы не можете полагаться на то, что компилятор всегда выбирает одно и то же определение, поскольку нарушение вышеупомянутого правила делает программу плохо сформированной. Компилятор также может диагностировать это и выдавать ошибку.

Если функция static или в анонимном пространстве имен, у вас есть две различные функции, называемые foo и компилятор должен выбрать тот из правильного файла.


Соответствующие стандарты для справки:

Встроенная функция должна быть определена в каждой единице перевода, в которой она используется и будет иметь точно
одно и то же определение в каждом случае (3.2)
. […]

7.1.2 / 4 в N4141, подчеркните мой.

31

Как уже отмечали другие, компиляторы соответствуют стандарту C ++, потому что Одно определение правила утверждает, что у вас должно быть только одно определение функции, за исключением случаев, когда функция встроенная, тогда определения должны быть одинаковыми.

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

Это свойство называется inline потому что до LTO (оптимизация времени соединения), взятие тела функции и «встраивание» его в сайт вызова требовало, чтобы у компилятора было тело функции. inline функции могут быть помещены в заголовочные файлы, и каждый файл cpp может видеть тело и «встроенный» код в сайт вызова.

Это не значит, что код на самом деле собирается быть встроенным; скорее, это облегчает компилятору встроить его.

Тем не менее, я не знаю о компиляторе, который проверяет идентичность определений, прежде чем отбрасывать дубликаты. Это включает в себя компиляторы, которые в противном случае проверяют определения тел функций на идентичность, такие как свертывание COMDAT MSVC. Это меня огорчает, потому что это очень тонкий набор ошибок.

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

Еще один действительно неприятный пример этого:

// 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 (поскольку нулевое число с плавающей запятой в большинстве ситуаций равно нулю).

В другом мы сначала инициализируем три sizeof(void*) с нуля, затем позвоните .reserve(100) на эти байты интерпретируя их как вектор.

Во время соединения одна из этих двух реализаций отбрасывается и используется другой. Более того, какой из них отбрасывается, вероятно, будет довольно детерминистическим в полной сборке. В частичной сборке это может изменить порядок.

Итак, теперь у вас есть код, который может работать и работать нормально в полной сборке, но частичная сборка вызывает повреждение памяти. А изменение порядка файлов в make-файлах может привести к повреждению памяти, или даже к изменению порядка связывания файлов lib, или обновлению вашего компилятора и т. Д.

Если оба файла cpp имели namespace {} Блок, содержащий все, кроме того, что вы экспортируете (который может использовать полные имена пространств имен), этого не могло произойти.

Я поймал именно эту ошибку в производстве несколько раз. Учитывая, насколько это неуловимо, я не знаю, сколько раз оно проскальзывало, ожидая момента, чтобы наброситься.

11

ТОЧКА РАЗЪЯСНЕНИЯ:

Хотя ответ, основанный на встроенном правиле C ++, является правильным, он применяется, только если оба источника скомпилированы вместе. Если они скомпилированы отдельно, то, как заметил один комментатор, каждый результирующий объектный файл будет содержать свой собственный foo (). ОДНАКО: если эти два объектных файла затем связаны вместе, то, поскольку оба ‘foo ()’ — не являются статичными, имя ‘foo ()’ появляется в экспортированной таблице символов обоих объектных файлов; затем компоновщик должен объединить две записи таблицы, поэтому все внутренние вызовы повторно привязаны к одной из двух подпрограмм (предположительно к той, которая была обработана в первом объектном файле, поскольку она уже связана [т.е. компоновщик будет рассматривать вторую запись как ‘extern ‘независимо от связывания]).

-3
По вопросам рекламы [email protected]