Сказание о static и неименованном пространстве имен для функции в C++
Команда разработчиков получила от руководства задание срочно написать приложение для обработки пользовательских данных. Руководитель быстро декомпозировал задачу на две и поручил одной команде сделать модуль подготовки данных, а другой — реализовать сами расчеты.
Итак, первая команда героически напряглась и дала миру следующий код:
// 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