Сказание о static и неименованном пространстве имен для функции в C++

70df3cea818bc96d6f11c94962a24621.jpg

Команда разработчиков получила от руководства задание срочно написать приложение для обработки пользовательских данных. Руководитель быстро декомпозировал задачу на две и поручил одной команде сделать модуль подготовки данных, а другой — реализовать сами расчеты.

Итак, первая команда героически напряглась и дала миру следующий код:

// parser.h
#pragma once

int process_data(int);
int parse_data(int);
// parser.cpp
#include "parser.h"

int process_data(int raw_data) {
	return 15;
}

int parse_data(int raw_data) {
	return process_data(raw_data);
}
// main.cpp
#include "parser.h"

int main() {
	const int raw_data = 1;
	const int data = parse_data(raw_data);
	return data;
}

И отправила его в master. Компиляция на сервере прошла успешно и ребята с чувством выполненного долга отправились вместе с их руководителем в бар.

Вторая команда тоже ответственно подошла к работе и написала модуль расчетов, не забыв сопроводить его емким комментарием:

// solver.h
#pragma once

int process_data(int);
int solve(int);
// solver.cpp
#include "solver.h"

int process_data(int temp_data) {
	// - Петька, приборы! - 30! - что 30? - А что "приборы"?
	return temp_data + 30;
}

int solve(int data) {
	return process_data(data);
}

Ребята знали, что первая команда уже написала заготовку кода в main.cpp, поэтому они просто сделали git pull и внесли кое-какие коррективы в код, чтобы активировать новый функционал:

// main.cpp
#include "parser.h"
++#include "solver.h"

int main() {
	const int raw_data = 1;
	const int data = parse_data(raw_data);
	--return data;
	++return solve(data);
}

Так сложилось, что в отделе разработки все очень любят использовать слово «process». И в этот раз так получилось, что вторая команда придумала название вспомогательной функции в точности совпадающее с той, что реализовала первая команда, но еще не знала об этом.

При попытке собрать новый код любимая IDE внезапно выдала грозное сообщение об ошибке:

multiple definition of `process_data(int)';

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

Начали выяснять. Самый молодой из них метко заметил, что в файле solver.cpp действительно два раза встречается упоминание «process_data», о чем и как бы и говорит ошибка:

int -->process_data<--(int temp_data) {
	return temp_data + 30;
}

int solve(int data) {
	return -->process_data<--(data);
}

Но тот, что по-опытнее ему аргументированно ответил: — «Не, мы так и раньше делали, ошибок не возникало».

Тот, что самый опытный из команды (дальше по тексту «Герой») решил более внимательно изучить сообщение об ошибке, полный текст которого представлен ниже:

/usr/bin/ld: /tmp/ccOIubul.o: in function `process_data(int)':
solver.cpp:(.text+0x1b): multiple definition of `process_data(int)'; /tmp/ccelFhGq.o:parser.cpp:(.text+0x1b): first defined here
collect2: error: ld returned 1 exit status

и увидел в нем упоминание файла, созданного другой командой — parser.cpp, а фраза «first defined here» как бы намекала, что та команда первой там что-то определила.
Казалось вот-вот решение найдется. Самое простое, что можно было сделать, это сказать руководителю, что у нас все хорошо, локально все собиралось, пока не смержили с кодом, сделанным другой командой, это наверняка они что-то сделали не так. Но на отправленные руководителю сообщения никто так и ответил, видимо классно проводят время в баре, подумал Штирлиц герой.

Делать все равно что-то нужно, стали искать помощи в интернете.
Первая ссылка по запросу «multiple definition of» привела на переведенный на русский язык совет на SO:

Исходник, в котором определяется XYZArray подключается два раза. Не следует подключать CPP-файлы через include, а в H-файлах должна быть проверка на повторное включение.

Проверка на повторное чтение есть, через инклюд подключаются только h-файлы. Неужели pragma не сработала? Слышал же где-то, что лучше писать через define. Переписали код на вариант с #ifndef/#define — не помогло.

В другом совете речь шла про какое-то связывание и статик. Чутье нашего героя посоветовало копать в эту сторону. Неспроста его сегодня утром сильно ударило статическим электричеством когда он потянулся перетыкать usb мышку. Дело в том, что кресло у товарища сделано из синтетики, как и штаны героя. Но он об этом пока не догадывается и списывает все на начавшиеся проявляться долгожданные сверхспособности.

По слову static применительно к языку С++ нашлось огромное количество ссылок. Оказалось, что его можно использовать в свободных функциях, методах класса, переменных, полях класса. Он решил пока не распыляться и сузить поиск до функций.

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

Так, вот гуглеж показал, что когда вы вызываете например команду:

g++ main.cpp parser.cpp solver.cpp

за кадром происходит много всего, в том числе некое связывание, а тот кто связывает — зовется линкером и он оповестил нас об ошибке:

/usr/bin/ld: ...multiple definition of ...

Вроде бы мы на верном пути, осталось понять причем тут static и что все-таки связывается.

Оказалось, что cpp-файлы из команды которая приведена выше на самом деле компилируются по-отдельности в т.н. объектные файлы — созданные ассемблером промежуточные файлы, хранящие куски машинного кода [1]. Это подтверждают и записи в сообщении об ошибке:

... /tmp/ccOIubul.o ... /tmp/ccelFhGq.o

Машинный код читать неинтересно, а ассемблерный для наших исходников выглядит так (точнее его часть):

g++ -S -O2 parser.cpp

	.file	"parser.cpp"
	.text
	.p2align 4
	.globl	_Z12process_datai
	.type	_Z12process_datai, @function
_Z12process_datai:
.LFB3:
	.cfi_startproc
	endbr64
	movl	$15, %eax
	ret
	.cfi_endproc
...
g++ -S -O2 solver.cpp

	.file	"solver.cpp"
	.text
	.p2align 4
	.globl	_Z12process_datai
	.type	_Z12process_datai, @function
_Z12process_datai:
.LFB0:
	.cfi_startproc
	endbr64
	leal	30(%rdi), %eax
	ret
	.cfi_endproc
...

Что мы видим? На первый взгляд ничего не понятно, но если присмотреться, то можно найти одинаковые названия в обоих листингах:

.globl	_Z12process_datai
...
_Z12process_datai:

_Z12process_datai до боли напоминает нашу несчастную функцию, которая фигурировала в сообщении об ошибке. Только у нее еще кое-что прилеплено спереди и сзади. То, что сзади, кстати очень похоже на тип аргумента функции — int. А некая метка .globl созвучна с чем-то глобальным, типа глобальная переменная, но видимо и к функциям оно тоже применимо.

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

Проблему локализовали, теперь нужно найти решение.

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

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

--int process_data(int);
++static int process_data(int);

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

g++ -S -O0 solver.cpp
	
	.file	"solver.cpp"
	.text
	.type	_ZL12process_datai, @function
_ZL12process_datai:
.LFB0:
	.cfi_startproc
	endbr64
....
_Z5solvei:
.LFB1:
	.cfi_startproc
	endbr64
	pushq	%rbp
....

И о чудо! В название функции добавилась буква L: _ZL12process_datai да и метка .globl исчезла! Вот оно счастье!

Но радость оказалась преждевременной. При попытке скомпилировать весь проект компилятор отрапортовал новой ошибкой:

g++ main.cpp parser.cpp solver.cpp 

In file included from main.cpp:2:
solver.h:3:12: error: ‘int process_data(int)’ was declared ‘extern’ and later ‘static’ [-fpermissive]
    3 | static int process_data(int);
      |            ^~~~~~~~~~~~
In file included from main.cpp:1:
parser.h:4:5: note: previous declaration of ‘int process_data(int)’
    4 | int process_data(int);

Сообщение

was declared ‘extern’ and later ‘static’

оказалось довольно часто встречаемым в интернете, но все ссылки уходили во времена чистого С, с которым наш герой не хотел разбираться. Поэтому он принял импульсивное решение — удалить из своего h-файла объявление функции int process_data (int), а static приписать к ее определению в cpp-файле:

--int process_data(int temp_data) {
++static int process_data(int temp_data) {

И сработало! Проект скомпилировался. Можно было бы радоваться, но что-то не давало покоя нашему герою.

А можно ли было не удалять объявление функции? А можно как-то по-другому ограничить область видимости функции? Что за extern? Почему много отсыла на язык С?

И да, оказалось, что есть второй способ скрыть функцию — использовать неименованное пространство имен или unnamed namespace по ихнему. Кстати, именно его рекомендуют в неких Core C++ Guidelines [2].

Чтобы это провернуть понадобилось обернуть определение функции в конструкцию вида namespace { ... }:

namespace {

int process_data(int temp_data) {
	return temp_data + 30;
}

}

Правда в заголовочном файле все-таки пришлось убрать ее объявление, т.к. выскакивала ошибка про ambiguous.

Проверка ассемблерного кода отдельно скомпилированного solver.cpp показала, что имя функции действительно поменялось:

	.file	"solver.cpp"
	.text
	.type	_ZN12_GLOBAL__N_112process_dataEi, @function
_ZN12_GLOBAL__N_112process_dataEi:
.LFB0:
	.cfi_startproc
	endbr64
.....

Весь проект также успешно скомпилировался. Наш герой решил, что на этом можно остановиться и залить все в мастер, как тут пришел долгожданный ответ от начальника:

Ребят, хорош там фигней страдать, просто переименуйте вашу функцию и давайте к нам в бар.

Так они и сделали, а потом на посиделках часто вспоминали этот случай.

Ссылки:
[1] https://habr.com/ru/articles/478124/
[2] https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#sf22-use-an-unnamed-anonymous-namespace-for-all-internalnon-exported-entities

Habrahabr.ru прочитано 10886 раз