Введение

Если вы когда-нибудь собирали проекты на C++, то практически наверняка получали ошибку от линковщика формата:

/usr/bin/ld: /tmp/ccYa2eaO.o: в функции «foo()»:
foo.cpp:(.text+0x0): повторное определение «foo()»; /tmp/ccKEs5I7.o:main.cpp:(.text+0x0): здесь первое определение
collect2: error: ld returned 1 exit status

Опытным C++ разработчикам эта проблема известна как ODR (One Definition Rule). Но корень этой ошибки выходит далеко за рамки «используй inline в .hpp» и «не определяй ничего в .hpp».

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

С чего всё началось

Когда мы говорим о связывании в C++, мне нравится проводить аналогию с инкапсуляцией в ООП. И действительно, мы хотим, чтобы наша программа давала потребителям «полезные» функции, а всякие «неприятные» и «некрасивые» детали прятала. К сожалению, C++ - очень открытый миру язык. Рассмотрим его гостеприимство на конкретном примере.

Представим, что у нас есть 3 файла:

├── bar.cpp
├── foo.cpp
└── main.cpp
// bar.cpp
int sqr(int x) { return x * x; }

// foo.cpp
int sqr(int x);

bool check(int a, int b, int c) { return sqr(a) + sqr(b) == sqr(c); }

// main.cpp
#include <iostream>

bool check(int a, int b, int c);

int main() {
  std::cout << check(1, 2, 3) << std::endl;
  return 0;
}

Попробуем скомпилировать нашу программу:

>> g++ -o main main.cpp foo.cpp bar.cpp

Программа успешно компилируется и работает, а ведь точка входа (main) явно и не знала о реализации функции check. Такое поведение возможно и даже корректно. Всё потому, что C++ по умолчанию считает функции и глобальные переменные (не являющиеся const или constexpr) внешними (external linkage), то есть доступными для других объектных файлов.

Чтобы это проверить, создадим объектный файл из bar.cpp и взглянем на его таблицу символов (утилита objdump):

>> g++ -c bar.cpp -o bar.o
>> objdump -t bar.o

bar.o:     формат файла elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 bar.cpp
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 g     F .text  0000000000000013 _Z3sqri

Обратим особое внимание на второй столбец. В процессе изучения темы мы получим 3 возможных варианта: l (local), g (global), w (weak) — именно эти значения определяют то, как линковщик будет «дружить» наш объектный файл с другими объектными файлами (соседями по сборке).

Какие виды связывания существуют

Стандарт определяет всего 3 вида связывания:

  • Внешнее связывание (external linkage)

  • Внутреннее связывание (internal linkage)

  • Без связывания (no linkage)

Рассмотрим каждый из них на примере.

Внешнее связывание (external linkage)

По умолчанию все глобальные переменные и объявления функций имеют внешнее связывание (external linkage). Внешнее связывание потенциально несет большую проблему: если на этапе компоновки линковщик обнаружит два идентичных символа с типом global - произойдет ошибка множественного определения. Поэтому любое определение внутри .hpp — потенциальная ошибка ODR.

// bar.cpp
int x;
int *y;
thread_local int z;
const int* u; // Интересный кейс работы с const

int foo() { return 1; }

Все переменные из списка выше будут размещены в единице трансляции (объектном файле) и, что для нас самое критичное, будут глобальными в таблице символов:

>> g++ -c bar.cpp -o bar.o
>> objdump -jC bar.o
...
0000000000000000 g     O .bss   0000000000000004 x
0000000000000008 g     O .bss   0000000000000008 y
0000000000000000 g       .tbss  0000000000000004 z
0000000000000010 g     O .bss   0000000000000008 u
0000000000000000 g     F .text  000000000000000f foo()

Особенную роль играют классы, перечисления (enum) и typedef — формально они имеют внешнее связывание, но objdump ничего по ним не покажет, так как это абстракция языка, о которой наши объектные файлы ничего не знают. Но роль классов мы также затронем.

Внутреннее связывание (internal linkage)

Интуитивно понятно, что если есть внешнее связывание, то будет и внутреннее (противоположность внешнему). При внутреннем связывании объекты внутри единицы трансляции недоступны для других объектных файлов.

Исторически добиться внутреннего связывания в C++ можно было при помощи ключевого слова static. Современный C++ относит такой способ сокрытия переменных и функций к антипаттернам и предлагает альтернативу в виде анонимных пространств имён (о которых мы поговорим чуть позже).

// bar.hpp
static int x;
static int *y;
static thread_local int z;
static const int* u;

static int foo() { return 1; }
>> g++ -c bar.cpp -o bar.o
>> objdump -jC bar.o
...
0000000000000000 l     O .bss   0000000000000004 x
0000000000000008 l     O .bss   0000000000000008 y
0000000000000000 l       .tbss  0000000000000004 z
0000000000000010 l     O .bss   0000000000000008 u
0000000000000000 l     F .text  000000000000000f foo()

Волшебное ключевое слово static сделало все наши переменные внутренними (l - local).

На собеседованиях часто используют хорошую уловку — спрашивают у кандидата, что будет, если одну static переменную включить в два различных .cpp файла:

// static.hpp
static int iuch = 5;

// iuch.cpp
#include "static.hpp"

// snl.cpp
#include "static.hpp"

Ответ на такой вопрос логично вытекает из предыдущего примера: static делает всё внутренним, значит, для каждой единицы трансляции создастся своя независимая копия переменной iuch.

Отсутствие связывания (no linkage)

Казалось бы, есть внутреннее и внешнее, но что значит «нет связывания»? По сути это означает, что имя сущности известно исключительно в рамках того блока кода, где оно объявлено. Линковщику эта переменная просто неинтересна, так как он физически не может её переиспользовать.

// bar.cpp
void bar() {
  int foo = 0; // no linkage
}

Сила в его слабости

В предыдущей главе мы разобрали типовые случаи, но, как обычно бывает, «дьявол кроется в деталях».

Ранее мы говорили, что таблица символов может возвращать weak, который подразумевает внешнее связывание (external linkage), но почему-то именуется слабым. При такой конфигурации (weak + external linkage) есть неоспоримое преимущество: линковщик выберет только одну реализацию, и именно ей будут пользоваться все остальные. Но есть и недостаток: если по какой-либо причине реализации различаются — мы получаем UB (Undefined Behavior).

Рассмотрим на примере:

├── bar.cpp
├── foo.cpp
└── main.cpp
// bar.cpp
#include <iostream>

struct Foo {
  static void foo() { std::cout << "Я люблю оливки" << std::endl; }
};

void external_bar() { Foo::foo(); }

// foo.cpp
#include <iostream>

struct Foo {
  static void foo() { std::cout << «Я не люблю оливки» << std::endl; }
};

void external_foo() { Foo::foo(); }

// main.cpp
void external_bar();
void external_foo();

int main() {
    external_bar();
    external_foo();
    return 0;
}

Мы уже знаем, что external_bar и external_foo имеют внешнее связывание и будут нам доступны из основной программы. Метод foo также является внешним, так как стандарт диктует, что все определения внутри класса получают неявный inline, но результат работы программы совершенно неясен. Ответ кроется в том, как мы скомпилируем нашу программу:

>> g++ main.cpp foo.cpp bar.cpp -o main
>> ./main
Я не люблю оливки
Я не люблю оливки

>> g++ main.cpp bar.cpp foo.cpp -o main
>> ./main
Я люблю оливки
Я люблю оливки

Одна и та же программа в зависимости от того, какую последовательность единиц трансляции мы задали, работает совершенно по-разному — вот вам и weak.

Вскрыв объектный файл bar.o, мы увидим причину:

0000000000000000  w    F .text._ZN3Foo3fooEv    0000000000000036 Foo::foo()

Наша функция является слабой (w), а значит, линковщик сам волен выбирать её реализацию.

А что же с шаблонами?

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

template <typename T> 
T sum(T x, T y) { return x + y; }

void f() { sum(1, 3); }
0000000000000000  w    F .text._Z3sumIiET_S0_S0_        0000000000000018 int sum<int>(int, int)

Анонимность не только в интернете…

Вспомним пример из главы «Сила в его слабости». А что, если и правда два разработчика случайно напишут идентичные классы с идентичными методами? Отдебажить подобную ошибку будет невероятно трудно, хотя и реально (в этом может помочь карта ссылок линковщика, которую можно получить флагом -Wl,-Map=map.txt, например: g++ main.cpp bar.cpp foo.cpp -Wl,-Map=map.txt -o main).

В случае с классами ситуация усугубляется тем, что static классов не бывает, а сам модификатор static для изменения связывания — антипаттерн. Ответ кроется в namespace {} — анонимных пространствах имён.

// bar.cpp
#include <iostream>
namespace {
struct Foo {
  void foo() { std::cout << "Я люблю оливки" << std::endl; }
};
} // namespace
void external_bar() {
  auto x = Foo();
  x.foo();
}

// foo.cpp
#include <iostream>
namespace {
struct Foo {
  void foo() { std::cout << "Я не люблю оливки" << std::endl; }
};
} // namespace
void external_foo() {
  auto x = Foo();
  x.foo();
}

// main.cpp
void external_bar();
void external_foo();

int main() {
    external_bar();
    external_foo();
    return 0;
}

Давайте посмотрим на примере bar.o, из чего состояла функция до её включения в анонимный namespace и после:

До:

#include <iostream>

struct Foo {
  void foo() { std::cout << "Я люблю оливки" << std::endl; }
};
void external_bar() {
  auto x = Foo();
  x.foo();
}
>> g++ -c bar.cpp -o bar.o
>> objdump -tC bar.o | grep "Foo::foo"
0000000000000000  w    F .text._ZN3Foo3fooEv    000000000000003e Foo::foo()

После:

#include <iostream>

namespace {
struct Foo {
  void foo() { std::cout << "Я люблю оливки" << std::endl; }
};
} // namespace
void external_bar() {
  auto x = Foo();
  x.foo();
}
>> g++ -c bar.cpp -o bar.o
>> objdump -tC bar.o | grep "Foo::foo"
0000000000000000 l     F .text  000000000000003a (anonymous namespace)::Foo::foo()

Мы видим, что по умолчанию foo была external и имела слабое связывание, но анонимность позволила превратить foo в internal (l — local). Это позволило избежать ODR, а также позволило нашей программе наконец-то твердо и чётко заявить о своем отношении к оливкам:

>> g++ main.cpp bar.cpp foo.cpp -o main
>> ./main
Я люблю оливки
Я не люблю оливки

Спецификаторы и их влияние на связывание

Спецификатор / Модификатор

Применимость

Тип связывания по умолчанию

Описание и особенности

Без модификаторов

Переменные и функции

External

Глобальная видимость, опасность ODR.

static

Переменные и функции

Internal

Ограничивает видимость рамками одной единицы трансляции (.cpp).

const

Переменные

Internal

Исторически const переменные имеют внутреннее связывание. Но будьте осторожны с указателями: const int* x имеет внешнее связывание, а вот int* const x - внутреннее (Очень рекомендую ответ ultraman на StackOverflow).

constexpr

Переменные

Internal

Гарантирует вычисление на этапе компиляции. Неявно подразумевает const, поэтому переменные получают внутреннее связывание.

constexpr

Функции

External (+ weak)

Неявно подразумевает inline, поэтому функция сохраняет внешнее связывание, но получает исключение из правила ODR.

inline

Функции, переменные (C++17)

External (+ weak)

Сохраняет внешнюю видимость, но разрешает множественные определения (спасает от ODR). Неявно применяется к методам, определенным внутри тела класса.

Анонимный namespace {}

Классы, переменные, функции

Internal

Альтернатива static для сокрытия любых сущностей внутри единицы трансляции (.cpp).

Вместо вывода

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

Спасибо большое за прочтение этой скромной статьи, буду рад комментариям под постом 🙌