Подтвердить что ты не робот

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

Ответ 1

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

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

...

  1. Может быть более одного определения типа класса (раздел 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", независимо от привязки]).