Вызвать функцию, которая не должна вызываться. Испортить стек. Сломать проверенную временем стороннюю библиотеку. Довести до безумия программиста, пытающегося найти проблему под отладчиком — все может ODR violation!
Вполне естественное и понятное правило, действующее во многих языках программирования: у одной и той же сущности должно быть не больше одного определения. Наличие двух и более определений, например, для функции, различающихся поведением, тут же приводит к проблеме: а какое же использовать.
В некоторых языках неопределенности нет — например, в Python, каждое следующее определение перекрывает предыдущее:
# hello.py
def hello():
print("hello world")
hello() # hello world
def hello():
print("Hello ODR!")
hello() # hello ODR!
В иных языках множественные определения просто приводят к ошибке компиляции
fun x y = x + y
gun x y = x - y
fun x y = x * y
main = print $ "Hello, world!" ++ (show $ fun 5 6)
-- Multiple declarations of ‘fun’
-- Declared at: 1124215805/source.hs:3:1
-- 1124215805/source.hs:7:1
С и С++ не исключения — в них переопределения функции, классов, шаблонов тоже диагностируются и выливаются в ошибку компиляции
int fun() {
return 5;
}
int fun() { // CE: redefinition
return 6;
}
И вроде бы все хорошо, ожидаемое, отличное решение. Но есть нюансы.
Для статического анализа, конечно, очень удобно, если весь ваш код живет в одном единственном файле. Но на практике обычно код разделяют на отдельные «модули», занимающиеся своей обособленной логикой. И вполне встречается ситуация, в которой два разных модуля содержат одноименные типы или функции. И это не должно вызывать проблем, должно работать из коробки... Но не в C/C++.
Знакомые с Python, наверное, знают, что в нем каждый отдельный файл — модуль — отдельное пространство имен. Имена классов и функции из разных файлов никак не интерферируют, до тех пор пока не будут импортированы.
В C никогда модулей не было и, скорее всего, не будет. Вместо них — раздельная компиляция, работающая на возможности оставлять сущности объявленными (например, в «подключаемых» заголовочных файлах), но не определенными (определение помещают в отдельную единицу трансляции, компилируемую независимо). Окончательная сборка и разрешение всех неопределенных имен откладывается до этапа линковки.
Никаких пространств имен также нет, и определение двух функций с одним и тем же именем в разных единицах трансляции — нарушает ODR и... почти наверняка не будет отловлено на этапе компиляции. Возможно, если вам повезет и вы не забыли настроить опции линковки, проблема будет выявлена на следующем этапе. А если же вам не повезет, вы попадете в цепкие лапы неопределенного поведения.
Наибольшую неприятность доставляет то, что проблема не ограничивается сборкой лишь вашего кода. Ведь вы можете случайно использовать какое-то имя, встречающееся в сторонней библиотеке! И тогда можно сломать эту библиотеку как в своем проекте, так и в чужом — если ваш код будет использоваться в качестве зависимости. Причем достаточно случайно угадать лишь имя функции: в С нет перегрузок функции и определение функции с тем же именем, но с другими аргументами — ODR violation.
Из-за всех этих проблем в стандартах C и С++ даже указаны ограничения на имена, которые вы можете использовать в своем коде, чтобы случайно не сломать стандартную библиотеку!
Что же делать?
В мире чистого C с этим борются комплексом методов:
- Ручной имплементацией механизма пространств имен — каждой функции и структуре в проекте дописывают префиксом имя проекта.
- Настраивают видимость символов:
static
делает функцию или глобальную переменную «не видимой» за пределами единицы трансляции.__attribute__((visibility("hidden")))
для приватных структур и функций- флаги
-fvisibility=hidden
,-fvisibility-inlines-hidden
и выставление атрибутов только для публичного интерфейса
- Пишут скрипты для линкера, если предыдущий пункт пропустил в итоговый бинарь что-то лишнее
Все это, возможно, спасет при интеграции с другими библиотеками. Но от переопределения ваших функций и структур внутри вашего же проекта — почти не помогает.
В C++ ситуация немного лучше.
Во-первых, есть перегрузки функций, типы аргументов участвуют в формировании имен, используемых при линковке, так что всего лишь угадать имя недостаточно, чтобы встрять в неприятности — нужно еще угадать аргументы (но не тип возвращаемого значения!)
Во-вторых, есть пространства имен, и вручную прописывать префиксы к каждой объявляемой функции не нужно.
В-третьих, есть анонимные пространства имен, позволяющие делать не видимым за пределами единицы трансляции, все, что определено внутри него:
// A.cpp
namespace {
struct S {
S() {
std::cout << "Hello A!\n";
}
};
}
void fun_A() {
S{};
}
// B.cpp
namespace {
struct S {
S() {
std::cout << "Hello B!\n";
}
};
}
void fun_B() {
S{};
}
Структуры S
находятся в разных анонимных пространствах имен, проблем с нарушением ODR не возникает
У меня в проекте долгое время существовали два определения вспомогательной приватной структуры префиксного дерева. Но эти определения не было помещены в анонимное пространство имен. Всё прекрасно работало до тех пор, пока однажды не поменяли порядок компиляции файлов. И сразу SEGFAULT — в объявлениях были разные типы полей, и при тестировании происходило настоящее безумие. Хорошо, что нашлось раньше, чем упало на боевом стенде.
Наконец, в C++, начиная с 20 стандарта, появились модули. Приватные, явно не экспортируемые, имена внутри одного модуля не интерферируют с именами из других модулей. Но для экспортируемых имен все проблемы сохраняются — объявлять пространство имен, следить за пересечениями надо самостоятельно.
Вместе с возможностями чуть реже нарушать ODR, в C++, конечно же, есть дополнительные возможности для неявного нарушения ODR — шаблоны.
Шаблоны инстанциируются в каждой единице трансляции. И при использовании одних и тех же параметров должны раскрываться в один и тот же код — чтобы не нарушить ODR.
В C++ мы можем определять функции, принадлежащие к какому угодно пространству имен, совершенно в любой единице трансляции. А шаблоны компилируются в два прохода, с привлечением ADL (argument dependent lookup). И горе вам, если один из проходов вытянет разные функции!
struct A {};
struct B{};
struct D : B {};
// demo_1.cpp
bool operator<(A, B) { std::cout << "demo_1\n"; return true; }
void demo_1() {
A a; D d; std::less<void> comparator;
comparator(a, d); // шаблонный оператор () ищет подходящее определение для <
}
// demo_2.cpp
bool operator<(A, D) { std::cout << "demo_2\n"; return true; }
void demo_2() { A a; D d; std::less<void> comparator; comparator(a, d); }
int main() {
demo_1();
demo_2();
return 0;
}
В этом примере (спасибо, LDVSOFT) разный порядок компиляции дает разные результаты:
- либо оба вызова печатают
demo_1
, - либо оба вызова печатают
demo_2
- возможно даже поиграть с компилятором так, чтоб работало как задумывалось — это остается читателю в качестве домашнего задания.
Занятно то, что из-за специфики и трудности реализации двухэтапной компиляции шаблонов, разные компиляторы будут давать разный результат, если поместить этот пример в одну единицу трансляции! И о проблеме никто не сообщит!
Для упрощения анализа, печать строк заменена на печать чисел 1 и 2
GCC:
demo_1():
mov esi, 1
mov edi, OFFSET FLAT:_ZSt4cout
jmp std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
demo_2():
mov esi, 1
mov edi, OFFSET FLAT:_ZSt4cout
jmp std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
MSVC:
void demo_1(void) PROC ; demo_1, COMDAT
push 2
mov ecx, OFFSET std::basic_ostream<char,std::char_traits<char> > std::cout ; std::cout
call std::basic_ostream<char,std::char_traits<char> > & std::basic_ostream<char,std::char_traits<char> >::operator<<(int) ; std::basic_ostream<char,std::char_traits<char> >::operator<<
ret 0
void demo_1(void) ENDP
void demo_2(void) PROC ; demo_2, COMDAT
push 2
mov ecx, OFFSET std::basic_ostream<char,std::char_traits<char> > std::cout ; std::cout
call std::basic_ostream<char,std::char_traits<char> > & std::basic_ostream<char,std::char_traits<char> >::operator<<(int) ; std::basic_ostream<char,std::char_traits<char> >::operator<<
ret 0
Код, собранный GCC печатает 11. MSVC — 22.
Страшно? Не бойтесь! Если в этом примере operator <
действительно предполагался приватным, то заворачивание его в анонимное пространство имен решило бы проблему: std::less<void>::operator()
его бы не нашел, вы бы получили ошибку компиляции (она бы вам не понравилась), пришлось бы использовать сравнение явно, и тут уже все определено.
Используйте модули или помещайте приватные кишки в анонимные пространства имен — и будет вам счастье. Наверное.
Если модули вам все еще не доступны, в мире C и C++ существует особый подход к организации процесса сборки проекта, который способен отлавливать ODR-violation — unity build. Но это скорее пробочный эффект. Сам же unity build прежде всего предназанчен для ускорения сборки за счет сокращения числа включений препроцессированных заголовков.
Например, если у вас есть два класса-компонента, разнесенные в независимые компилируемые файлы
// unit.cpp
#include <algorithm>
struct Unit {};
// weapon.cpp
#include <algorithm>
struct Weapon {};
То при независимой компиляции этих файлов, каждый из них будет препроцессирован отдельно, а благодаря печально известному заголовку <algorithm>
каждый препроцессированный файл выйдет в десятки тысяч строк величиной!
Например с GCC 13.2
g++ -E unit.cpp | wc -l
17710 # 17 тысяч строк!
g++ -E unit.cpp | wc -c
443562 # около 443 KB!
Подход unity build же состоит в том, чтоб предварительно включить все комрилируемые файлы в один общий
// unity_build.cpp
#include "unit.cpp"
#include "weapon.cpp"
И разумеется в таком случае, если мы нарушим ODR, использовав в разных файлах одно и то же имя для разных сущностей, мы получим ошибку компиляции — ведь теперь у нас один файл компилируется!
Однако стоит иметь в виду, что у unity build есть серьезные недостатки
- Этот подход может уменьшить время сборки с нуля, но значительно увеличить время пересборки при изменениях — нужно будет перекомпилировать всё!
- Вместо ODR violation можно внезапно получить выбор неправильных перегрузок и сопутствующие баги в логике!
// weapon.cpp
namespace {
double calculate_damage(double x) {
return x * 10;
}
}
double Weapon::damage() {
return calculate_damage(15.0);
}
// unit.cpp
namespace {
int calculate_damage(int x) {
return x / 10;
}
}
void Unit::assign_damage(double x) {
this->hp -= calculate_damage(x);
}
При включении файлов в порядке
#include "unit.cpp"
#include "weapon.cpp"
поведение метода Unit::assing_damage
будет таким же как при независимой сборке.
Unit::assign_damage(double):
...
mov edi, eax
call (anonymous namespace)::calculate_damage(int)
Но при включении в другом порядке
#include "weapon.cpp"
#include "unit.cpp"
Перегрузка из анонимного пространства имен в файле weapon.cpp
будет более подходящей и поведение поменяется!
Unit::assign_damage(double):
...
movq xmm0, rax
call (anonymous namespace)::calculate_damage(double)
ODR violation почти всегда ходит вместе с проблемами обновлений и слома ABI:
Вы обновили библиотеку, и теперь ваш код зависит от ее более новой версии — убедитесь, что другой код, зависящий от вашего, также использует новую версию этой библиотеки. Или хотя бы бинарно совместимую. Иначе — ODR violation, слом стека, нарушение конвенции вызова... ну, вы в курсе.
Слом ABI, потенциальное нарушение ODR — одни из самых острых причин, почему миграция на новые версии стандарта, компиляторов и библиотек в C++ мире занимают многие годы. Нужно все пересобрать. Все перетестить. Убедиться, что никто не привнес неправильных имен.
Как это ни парадоксально, но возможность нарушить ODR иногда оказывается полезной.
Неопределенной поведение, с ним связанное, является в каком-то смысле определенным и контролируемым:
какое из определений будет использоваться задается порядком, на который можно влиять. GCC, например, поддерживает __attribute__((weak))
для пометки функций, которые ожидаемо будут замещаться альтернативными определениями (с более эффективной реализацией, без отладочных инструкций, например).
Или же техника symbol hooking, использующая LD_PRELOAD
, чтобы заменить определенные функции из динамических библиотек: для отладки с инструментированным аллокатором или же для перехвата вызовов и сбора статистики.