Внутренняя и внешняя линковка в C++

Внутренняя и внешняя линковка в C++. Мы расскажем, что это, зачем нужно, и покажем, как использовать на конкретных примерах.

Всем добрый день!

Представляем вам перевод интересной статьи, который подготовили для вас рамках курса «Разработчик C++». Надеемся, что она будет полезна и интересна вам, как и нашим слушателям.

Поехали.

Сталкивались ли вы когда-нибудь с терминами внутренняя и внешняя связь? Хотите узнать, для чего используется ключевое слово extern, или как объявление чего-то static влияет на глобальную область? Тогда эта статья для вас.

В двух словах

В единицу трансляции включены файл реализации (.c/.cpp) и все его заголовочные файлы (.h/.hpp). Если внутри единицы трансляции у объекта или функции есть внутреннее связывание, то этот символ виден компоновщику только внутри этой единицы трансляции. Если же у объекта или функции есть внешнее связывание, то компоновщик сможет видеть его при обработке других единиц трансляции.

Использование ключевого слова static в глобальном пространстве имен дает символу внутреннее связывание. Ключевое слово extern дает внешнее связывание.
Компилятор по умолчанию дает символам следующие связывания:

  • Non-const глобальные переменные – внешнее связывание.
  • Const глобальные переменные – внутреннее связывание.
  • Функции – внешнее связывание.

Основы

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

  • Разница между объявлением и определением
  • Единицы трансляции

Также обратите внимание на названия: мы будем использовать понятие “символ”, когда речь идет о любой “сущности кода”, с которой работает компоновщик, например с переменной или функцией (или с классами/структурами, но на них мы не будем акцентироваться).

Объявление VS Определение

Кратко обсудим разницу между объявлением и определением символа: объявление (или декларация) говорит компилятору о существовании конкретного символа, и позволяет обращение к этому символу в случаях не требующих точного адреса памяти или хранилища символа. Определение говорит компилятору, что содержится в теле функции, или сколько памяти нужно выделить переменной.

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

Чтобы получить значение по этому указателю, требуется определение. Также для объявления функции нужно объявить (но не определить) все параметры (неважно, взятые ли по значению, ссылке или указателю) и возвращаемый тип. Определение типа возвращаемого значения и параметров необходимо только для определения функции.

Функции

Разница между определением и объявлением функции весьма очевидна.

int f();               // объявление
int f() { return 42; } // определение

Переменные

С переменными все немного иначе. Объявление и определение обычно не разделяются. Главное, что это:

int x;

Не только объявляет x, но и определяет его. Происходит это благодаря вызову дефолтного конструктора int. В C++, в отличие от Java, конструктор простых типов вроде int по умолчанию не инициализирует значение в 0. В примере выше х будет равен любому мусору, лежащему в адресе памяти, выделенном компилятором.

Но вы можете явно разделить объявление переменной и ее определение при помощи ключевого слова extern.

extern int x; // объявление
int x = 42;   // определение

Однако при инициализации и добавлении extern к объявлению выражение превращается в определение, и ключевое слово extern становится бесполезным.

extern int x = 5; // то же самое, что и 
int x = 5;

Предварительное объявление

В C++ существует концепция предварительного объявления символа. Это значит, что мы объявляем тип и имя символа для использования в ситуациях, не требующих его определения. Так нам не понадобится включать полное определение символа (обычно – заголовочный файл) без явной необходимости. Тем самым мы снижаем зависимость от файла, содержащего определение. Главное преимущество – при изменении файла с определением файл, где мы предварительно объявляем этот символ, не потребует повторной компиляции (а значит и все прочие включающие его файлы).

Пример

Предположим, у нас есть объявление функции (называемое прототипом) для f, принимающее объект типа Class по значению:

// file.hpp

void f(Class object);

Сразу включить определение Class – наивно. Но так как мы пока только объявили f, достаточно предоставить компилятору объявление Class. Таким образом, компилятор сможет узнать функцию по ее прототипу, а мы сможем избавиться от зависимости file.hpp от файла, содержащего определение Class, скажем, class.hpp:

// file.hpp

class Class;

void f(Class object);

Допустим, file.hpp содержится в 100 других файлах. И, допустим, мы меняем определение Class в class.hpp. Если мы добавим class.hpp в file.hpp, file.hpp, все 100 содержащих его файла должны будут перекомпилироваться. Благодаря предварительному объявления Class единственными файлами, требующими повторной компиляции, будут class.hpp и file.hpp (если считать, что f определен там).

Частота использования

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

int f();
int f();
int f();
int f();
int f();
int f();
int f() { return 5; }

А это не работает:

int f() { return 6; }
int f() { return 9; }

Единицы трансляции

Программисты обычно работают с заголовочными файлами и файлами реализации. Но компиляторы – они работают с единицами трансляции (translation units, кратко – TU), которые иногда называют единицами компиляции. Определение такой единицы довольно простое – любой файл, переданный компилятору, после его предварительной обработки.

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

Есть следующие файлы:

header.hpp:

#ifndef HEADER_HPP
#define HEADER_HPP

#define VALUE 5

#ifndef VALUE
struct Foo { private: int ryan; };
#endif

int strlen(const char* string);

#endif /* HEADER_HPP */

program.cpp:

#include "header.hpp"

int strlen(const char* string)
{
	int length = 0;

	while(string[length]) ++length;

	return length + VALUE;
}

Препроцессор выдаст следующую единицу трансляции, которая затем передается компилятору:

int strlen(const char* string);

int strlen(const char* string)
{
	int length = 0;

	while(string[length]) ++length;

	return length + 5;
}

Связи

Обсудив основы, можно приступить к связям. В целом, связь – это видимость символов для компоновщика при обработке файлов. Связь может быть либо внешней, либо внутренней.

Внешняя связь

Когда символ (переменная или функция) обладает внешней связью, он становится видимым компоновщикам из других файлов, то есть “глобально” видимым, доступным всем единицами трансляции. Это значит, что вы должны определить такой символ в конкретном месте одной единицы трансляции, обычно в файле реализации (.c/.cpp), чтобы у него было только одно видимое определение.

Если вы попытаетесь одновременно с объявлением символа выполнить его определение или поместить определение в файл к объявлению, то рискуете разозлить компоновщик. Попытка добавить файл больше чем в один файл реализации ведет к добавлению определения больше чем в одну единицу трансляции – ваш компоновщик будет "плакать".

Ключевое слово extern в C и C++ (явно) объявляет, что у символа есть внешняя связь.

extern int x;
extern void f(const std::string& argument);

Оба символа имеют внешнюю связь. Выше отмечалось, что const глобальные переменные по умолчанию имеют внутреннее связывание, non-const глобальные переменные – внешнее.

Это значит, что int x; – то же самое, что и extern int x;, верно? Не совсем. int x; на самом деле аналогичен extern int x{}; (используя синтаксис универсальной/скобочной инициализации, для избежания самого неприятного синтаксического анализа (the most vexing parse)), так как int x; не только объявляет, но и определяет x.

Следовательно, не добавить extern к int x; глобально настолько же плохо, как и определить переменную при объявлении ее extern:

int x;          // то же самое, что и 
extern int x{}; // скорее всего приведет к ошибке компоновщика.

extern int x;   // а это только объявляет целочисленную переменную, что нормально

Плохой Пример

Давайте объявим функцию f с внешней связью в file.hpp и там же определим ее:

// file.hpp

#ifndef FILE_HPP
#define FILE_HPP

extern int f(int x);

/* ... */

int f(int) { return x + 1; }

/* ... */

#endif /* FILE_HPP */

Обратите внимание, что добавлять здесь extern не нужно, так как все функции явно extern. Разделения объявления и определения тоже не потребуется. Поэтому давайте просто перепишем это следующим образом:

// file.hpp

#ifndef FILE_HPP
#define FILE_HPP

int f(int) { return x + 1; }

#endif /* FILE_HPP */

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

Давайте посмотрим, почему так делать не стоит. Теперь у нас есть два файла реализации, a.cpp и b.cpp, оба включены в file.hpp:

// a.cpp

#include "file.hpp"

/* ... */
// b.cpp

#include "file.hpp"

/* ... */

Теперь пусть поработает компилятор и сгенерирует две единицы трансляции для двух файлов реализации выше (помните что #include буквально означает копировать/вставить):

// TU A, from a.cpp

int f(int) { return x + 1; }

/* ... */
// TU B, from b.cpp

int f(int) { return x + 1; }

/* ... */

На этом этапе вмешивается компоновщик (связывание происходит после компиляции). Компоновщик берет символ f и ищет определение. Сегодня ему повезло, он находит аж два! Одно в единице трансляции A, другое в B. Компоновщик замирает от счастья и говорит вам примерно следующее:

duplicate symbol __Z1fv in:
/path/to/a.o
/path/to/b.o

Компоновщик находит два определения для одного символа f. Поскольку у f есть внешнее связывание, он виден компоновщику при обработке и A, и B. Очевидно, это нарушает Правило Одного Определения и вызывает ошибку. Точнее это вызывает ошибку повторяющегося символа (duplicate symbol error), которую вы будете получать не реже, чем ошибку неопределенного символа (undefined symbol error), возникающую, когда вы объявили символ, но забыли определить.

Использование

Стандартным примером объявления переменных extern являются глобальные переменные. Предположим, вы работаете над самовыпекаемым тортом. Наверняка есть глобальные переменные, связанные с тортом, которые должны быть доступны в разных частях вашей программы. Допустим, тактовая частота съедобной схемы внутри вашего торта. Это значение естественно требуется в разных частях для синхронной работы всей шоколадной электроники. (Злой) C-способ объявления такой глобальной переменной имеет вид макроса:

#define CLK 1000000

Программист C++, испытывающий к макросам отвращение, лучше напишет настоящий код. Например, такой:

// global.hpp

namespace Global
{
	extern unsigned int clock_rate;
}

// global.cpp
namespace Global
{
	unsigned int clock_rate = 1000000;
}

Современный программист C++ захочет использовать разделительные литералы: unsigned int clock_rate = 1'000'000;.

Внутренняя связь

Если у символа есть внутренняя связь, то он будет виден только внутри текущей единицы трансляции. Не путайте видимость с правами доступа, например, private. Видимость означает, что компоновщик сможет использовать этот символ только при обработке единицы трансляции, в которой был объявлен символ, а не позже (как в случае символов с внешней связью).

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

Для объявления символа с внутренней связью, в C и C++ существует ключевое слово static. Такое использование отличается от применения static в классах и функциях (или в целом в любых блоках).

Пример

header.hpp:

static int variable = 42;

file1.hpp:

void function1();

file2.hpp:

void function2();

file1.cpp:

#include "header.hpp"

void function1() { variable = 10; }

file2.cpp:

#include "header.hpp"

void function2() { variable = 123; }

main.cpp:

#include "header.hpp"
#include "file1.hpp"
#include "file2.hpp"

#include <iostream>

auto main() -> int
{
	function1();
	function2();

	std::cout << variable << std::endl;
}

Каждая единица трансляции, включающая header.hpp, получает уникальную копию переменной в силу наличия у нее внутренней связи. Есть три единицы трансляции:

  1. file1.cpp
  2. file2.cpp
  3. main.cpp

При вызове function1 копия переменной file1.cpp получает значение 10. При вызове function2 копия переменной file2.cpp получает значение 123. Однако значение, которое выдается в main.cpp, не меняется и остается равным 42.

Анонимные пространства имен

В С++ существует другой способ объявления одного и более символов с внутренней связью: анонимные пространства имен. Такое пространство гарантирует, что символы, объявленные внутри него, видны только в текущей единице трансляции. По сути, это просто способ объявить несколько символов static.

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

В любом случае, это:

namespace { int variable = 0; }

Делает почти то же самое, что и:

static int variable = 0;

Использование

Так в каких же случаях пользоваться внутренними связями? Использовать их для объектов – плохая идея. Расход памяти больших объектов может быть очень высок из-за копирования под каждую единицу трансляции. Но в основном это просто вызывает странное, непредсказуемое поведение. Представьте, что у вас есть синглтон (класс, в котором вы создаете экземпляр только одного инстанса), и неожиданно появляется несколько инстансов вашего “синглтона” (по одному на каждую единицу трансляции).

Однако внутреннюю связь можно использовать для скрытия из глобальной области локальных хелпер-функций единицы трансляции.

Допустим, есть хелпер-функция foo в file1.hpp, которую вы используете в file1.cpp. В то же время у вас есть функция foo в file2.hpp, используемая в file2.cpp. Первая и вторая foo отличаются друг от друга, но вы не можете придумать другие имена. Поэтому вы можете объявить их static. Если вы не будете добавлять и file1.hpp, и file2.hpp в одну и ту же единицу трансляции, то это скроет foo друг от друга. Если этого не сделать, то они будут неявно иметь внешнюю связь, и определение первой foo столкнется с определением второй, вызвав ошибку компоновщика о нарушении правила одного определения.

THE END

Вы всегда можете оставить свои комментарии и/или вопросы тут или зайти к нам на день открытых дверей.

Еще больше полезных материалов:

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

matyushkin
29 марта 2020

ТОП-10 книг по C++: от новичка до профессионала

Книги по C++ на русском языке с лучшими оценками. Расставлены в порядке воз...
Библиотека программиста
31 января 2019

Лучшие инструменты и советы начинающему C++ программисту

Хотите изучать C++? Делимся важными навыками, фреймворками и советами, кото...