Виды связываний (external, internal, no linkage) для самых маленьких

от автора

Введение

Если вы когда-нибудь собирали проекты на 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.cppint sqr(int x) { return x * x; }// foo.cppint 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.obar.o:     формат файла elf64-x86-64SYMBOL TABLE:0000000000000000 l    df *ABS*  0000000000000000 bar.cpp0000000000000000 l    d  .text  0000000000000000 .text0000000000000000 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.cppint x;int *y;thread_local int z;const int* u; // Интересный кейс работы с constint foo() { return 1; }

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

>> g++ -c bar.cpp -o bar.o>> objdump -jC bar.o...0000000000000000 g     O .bss   0000000000000004 x0000000000000008 g     O .bss   0000000000000008 y0000000000000000 g       .tbss  0000000000000004 z0000000000000010 g     O .bss   0000000000000008 u0000000000000000 g     F .text  000000000000000f foo()

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

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

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

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

// bar.hppstatic 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 x0000000000000008 l     O .bss   0000000000000008 y0000000000000000 l       .tbss  0000000000000004 z0000000000000010 l     O .bss   0000000000000008 u0000000000000000 l     F .text  000000000000000f foo()

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

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

// static.hppstatic int iuch = 5;// iuch.cpp#include "static.hpp"// snl.cpp#include "static.hpp"

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

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

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

// bar.cppvoid 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.cppvoid 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; }};} // namespacevoid external_bar() {  auto x = Foo();  x.foo();}// foo.cpp#include <iostream>namespace {struct Foo {  void foo() { std::cout << "Я не люблю оливки" << std::endl; }};} // namespacevoid external_foo() {  auto x = Foo();  x.foo();}// main.cppvoid 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; }};} // namespacevoid 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, как наши объектные файлы видят соседи по компоновке и как различные спецификаторы раскрывают и закрывают доступ извне к переменным и функциям.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1063468/