
Вопрос ABI (Application Binary Interface), бинарной границы и бинарной совместимости в C++, раскрыт на просторах интернета не так хорошо как хотелось бы. Особенно сложно в его изучении приходится новичкам, потому что эта тема связана со множеством деталей нарочно скрытой от глаз программиста имплементации языка.
Данный материал задуман как стартовая точка, с которой вы можете начать более детальное ознакомление с терминами бинарной совместимости и общей проблематикой при пересечении бинарной границы.
Подробнее о статье
Мир С++ многое скрывает от глаз программиста. Это свойство языка — обоюдоострый меч, который с одной стороны позволяет уменьшить порог входа, но в то же время является препятствием при написании программ, уходящих дальше стандартного примера.
В первой части статьи я поделюсь теоретическими знаниями о том, что такое ABI и бинарная граница, и какие проблемы могут возникнуть при её пересечении.
Во второй части статьи я дам прикладные советы с примерами кода о том, как мы можем сделать переход бинарной границы безопаснее, изменив дизайн функций, и как при этом можно передавать информацию об ошибках из одного модуля в другой.
В рамках всей статьи будут рассмотрены темы: системный стек, системные регистры, динамическая память, детали механизма виртуальных функций, стандарт С++ и реальная имплементация, детали механизма исключений, copy elision при линковке библиотек, шаблоны, POD-типы и ODR violation.
Оглавление
UPD: Содержит список апдейтов с момента публикации. Последнее изменение: 18.10.23.
1. Необходимая теория
Прежде чем приступить к разбору что такое бинарная граница и ABI, давайте условимся насчёт обозначений. А конкретно, насчёт того, что я имею ввиду в данной статье, когда говорю программный модуль.
Под программным модулем я понимаю некоторый артефакт, который мы получаем после компиляции нашей программы, вроде: статической/динамической библиотеки, или исполняемого файла.
Когда я говорю программный модуль в этой статье, я не подразумеваю поддержку модулей из C++20.
1.1. Что такое бинарная граница
Бинарная граница — в моём понимании, это место, находящееся на границах инструкций двух программных модулей. Возможно, проще понять что такое бинарная граница, поняв когда мы её переходим.
Например: переход бинарной границы происходит в тот момент, когда наш поток, исполняющий код файла a.cpp
, который был скомпилярован в исполняемый файл a.exe
, вызывает функцию, определённую в файле b.cpp
, скомпилированную в библиотеку b.dll
(или b.lib
), которую исполняемый файл:
Загрузил уже после своего запуска (в случае динамической линковки, т.е. использования динамической библиотеки .
so
/.dll
),Или же эта библиотека была встроена в исполняемый файл на этапе компиляции этого исполняемого файла (в случае статической линковки, т.е. использования статической библиотеки
.a
/.lib
),А ещё есть комбинированный вариант, когда у нас есть и статическая и динамическая библиотеки, когда статическая предоставляет таблицу экспорта символов из динамической библиотеки.
Из-за чего могут происходить проблемы при пересечении бинарной границы?
В основном из-за того, что два программных модуля могут иметь разные правила по которым в них работают базовые механизмы языка.
Если приводить аналогию:
Одевая объект в шубу в одном модуле, после пересечения границы, в другом модуле, этот объект может обнаружить тропики, в которых местная фауна посчитает объект мохнатым зверем и начнёт охоту на него.
1.2. Что такое ABI
Компилятор, при сборке модуля, определяет для программы свои правила, и от компилятора к компилятору они могут различаться. Эти правила называются Application Binary Interface (ABI) приложения. На практике к ABI относится:
Способ взаимодействия программы с ОС
Реализация базовых механизмов языка:
Реализация механизма исключений.
Конвенция вызовов методов.
Реализация динамического полиморфизма (a.k.a. полиморфизма подтипов, a.k.a. "механизма виртуальных функций").
Реализация виртуального наследования.
и так далее...
Если же спуститься на более низкий уровень абстракции, к более базовым механизмам языка, то ABI описывает:
Способы использования регистров процессора.
Говоря иначе, тут декларируется в каком регистре что хранится и куда что записывать:где хранится возвращаемое значение из функции
где и в каком порядке хранятся значения, переданные в функцию
и так далее.
На самом деле, именно мы, как программисты на C++, обычно не определяем обязанности регистров. В программировании на С++ мы, зачастую (опуская ассемблерные вставки), не имеем дела с регистрами напрямую.
Всё благодаря тому, что другие программисты — разработчики компилятора, уже реализовали все нужные абстракции, поддержали один из стандартов С++ в своём компиляторе, чтобы мы не разбирались с тем как использовать такие низкоуровневые абстракции как регистры.
Стремление к удобству и ускорению скорости разработки, это, в частности, причины появления более высокоуровневых языков программирования — мы обмениваем часть свободы и гибкости низкоуровневого языка, взамен получая язык программирования, более похожий на человеческий язык. Получившийся инструмент позволяет описывать свои мысли в коде быстрее, потому что он более понятен нам на интуитивном уровне.
Стоит упомянуть, что область применения регистров немного пересекается с тем, зачем используется системный стек, о котором я говорю следующем пункте.Организацию системного стека.
Эта структура данных хранится в оперативной памяти программы. Системный стек полезен нам тем, что:Системный стек позволяет нам хранить значения внутри контекста (например, тела функции, или блока ограниченного фигурными скобками
{}
).
Когда вы создаёте переменную в программе, её значение сохраняется в текущем кадре стека. При вызове функции в стек добавляется новый кадр. При выходе из функции, текущий кадр удаляется со стека, вместе со связанными значениями переменных.Системный стек позволяет передавать данные из одного контекста программы в другой (например, из одной функции в другую).
При вызове функции, вы можете передать ей в качестве аргумента некоторое значение. Это значение будет помещено на стек перед вызовом функции, и, уже внутри функции будет изъято.Системный стек ограничивает время жизни локальных переменных, разрушая их при выходе из контекста, ограниченного фигурными скобками
{}
.
Упрощённый пример:
Локальные переменные из текущей функцииvoid a()
сохраняются на кадре стека сid==0
, и, при вызове функцииvoid b()
из функцииvoid a()
, на стек помещается новый, "чистый" от локальных переменных кадр сid==1
. При возврате из функцииvoid b()
, этот новый кадр сid==1
удаляется из стека, тем самым оставляя в стеке только исходный кадр сid==0
(со значениями функцииvoid a()
). При удалении кадра стека вызываются деструкторы для всех локальных переменных, находящемся на этом кадре.
В частности, из-за этого механизма, в общем случае (не передавая ссылок), мы не имеем доступа к локальным переменным из одной функции в контексте другой функции. Данный механим так же известен под именем "область видимости переменных".
Один из подводных камней системного стека — его максимальный размер фиксирован. Это означает, что, если мы будем бесконечно производить рекурсивные вызовы, то размер стека будет увеличиваться вплоть до его максимально допустимого размера, после чего произойдёт ошибка, называемая stack overflow (переполнение стека), название которой было взято за основу одноименного сайта, помогающего всем страждущим.Работу с динамической памятью (диспетчером памяти ОС).
Чтобы дать программисту возможность использовать больше оперативной памяти, чем может вместить системный стек, была придумана такая абстракция, как динамическая память, доступ к которой осуществляется через диспетчера памяти ОС.
Важно понимать, что ОС нарочно делает вид, будто бы у неё есть бесконечное количество памяти, которую процесс нашей программы может запросить. Используя различные инструменты, вроде виртуальной памяти, дефрагментации памяти и т.п.
Но на самом же деле количество оперативной памяти в ОС строго ограничено. На моём опыте, ситуация, в которой программе не хватает свободного места, чаще всего не обрабатывается в современных, не очень требовательных к отказоустойчивости, программах. В результате, вполне ожидаемый исход такого "голодания" по ресурсу динамической памяти — падение процесса, которому этой памяти не хватило.
Вцелом, 8-ми гигабайт ОЗУ обычно достаточно, чтобы среднестатистическая программа смогла работать без перебоев. Но если его недостаточно, то вы всегда можете просто докупить ещё больше ОЗУ и вставить новую плашку оперативки в материнскую плату, либо увеличить размер swap file. Если только ваша программа не содержит утечек памяти, потому что иначе, сколько бы памяти у вас не было, она вся утечёт. В частности, поэтому важно не допускать утечек памяти.и так далее...
1.3. Стандарт C++ и ABI
На этом этапе, вы, возможно, могли задаться вопросом:
Разве стандарт С++ не фиксирует единый ABI для всех компиляторов?
И ответ, к сожалению или счастью, отрицательный. Стандарт со своей стороны описывает свойства всех перечисленных механизмов (что они должны делать), но он не настаивает на их конкретной реализации (как они должны это делать). Максимум, который позволяет себе стандарт С++, это дать нестрогие рекоммендации по реализации.
Например, стандарт не обязывает реализовывать виртуальные функции именно через таблицу виртуальных функций (a.k.a. vtable
).
Что такое виртуальные функции и зачем они нужны?
Виртуальные функции — это механизм, необходимый для функционирования полиморфизма подтипов, так же известного как динамическая диспетчеризация вызовов функций. С точки зрения конструкций языка, этот механизм прячется за ключевым словом virtual
и реализацией наследования классов.
То есть это виртуальные функций, это тот самый базис, который позволяет нам описать и использовать единый интерфейс для нескольких сущностей, при этом не прибегая к метапрограммированию (a.k.a. обобщённому программированию/шаблонам).
Классический учебный пример задачи, которую можно решить с помощью виртуальных функций и интерфейсов: в вашей игре надо реализовать поведение разных уток (fly
, quack
, swim
), в бизнес-требованиях обозначены следующие виды:
Утка обыкновенная.
Утка-мандаринка.
Охотничья утка-приманка.
Суть проблемы сводится к тому, что поведение у каждого из типов уток — разное. Все утки умеют крякать, но то как крякает обычная утка отличается от того как крякает охотничья утка-приманка и утка-мандаринка. А так же все утки умеют плавать, но, как вы уже поняли, утка-приманка просто плывёт по течению. По понятным причинам утка-приманка сможет полететь только если её снабдить реактивным ранцем.
А ещё в бизнес-требованиях сказано, что в будущем вероятно появятся большое количество новых видов уток со своим уникальным поведением. Это важный нюанс, потому что вы, как программист этой логики, можете подготовить почву для других программистов (или вас же), чтобы расширение этой логики другими утками происходило быстрее, занимало меньше трудозатрат.
Обсуждение решения выходит за рамки этой статьи, но, вкратце, в качестве решения можно:
Создать интерфейс
IDuck
, в котором объявить все методы (fly
,quack
,swim
), после чегоoverload
-нуть (перегузить) эти методы в классах потомков (SimpleDuck
,MandarinDuck
,DummyDuck
).Или же, можно использовать паттерн проектирования strategy (стратегия), и сделать на каждый из методов (
fly
,quack
,swim
) по отдельному интерфейсу (IFlying
,IQuacking
,ISwimming
) со своей реализацией.
И даже если два компилятора будут использовать таблицу виртуальных функций под капотом, то порядок хранения указателей в этой таблице может отличаться. Что в свою очередь может привести к тому, что один модуль будет вызывать первую функцию в vtable
со своей стороны, а у другого модуля эта функция является последней в vtable
. Таким образом, мы неминуемо столкнёмся с UB или SIGSEGV
(сигнал, который вырабатывается программой в случае неверного обращения с памятью), или любым иным прекрасным творением мира С++ (и не только), так упорно пытающимся подавить нашу волю к жизни.
Ключевая идея, которую стоить понять, что ABI, и в целом логика работы базовых механизмов языка, может различаться:
В разных компиляторах.
И даже в рамках разных версий одного и того же компилятора.
И даже в рамках одной версии одного компилятора, но с разными флагами компиляции.
Мы, как программисты, обычно не соприкасаемся с реализацией таких вещей напрямую, поскольку они фигурируют только на затворках компиляции в недрах компилятора, и, к тому же, зависят от ОС.
Если раньше вы ничего не слышали про ABI, то это не беда. Так получилось, потому что эта информация была скрыта намеренно.
С одной стороны, сокрытие информации о реализации ABI имеет свои плюсы, среди которых я вижу важным то, что он уменьшает порог входа в программирование на C++, который и без того большой.
А с другой стороны, когда мы говорим про взаимодействие двух программных модулей, если не отдавать себе отчёт о совместимости их бинарного интерфейса, то можно получить UB. Программа с неопределённым поведением обычно выглядит как чёрный цилиндр, из которого на тебя с некоторой вероятностью может выпрыгнуть тиранозавр (ну или SIGSEGV
), что может отбить желание к существованию в целом.

1.4. И что с этим всем делать?

Ладно, я, конечно же нагнетаю. Ситуация не настолько страшная. Поскольку переход бинарной границы тесно связан с резолвингом внешних символов линкуемых модулей, чем занимается линковщик, то, в некоторых случаях, он может защитить нас от подобных проблем, определив и выдав ошибку линковки, если ABI у линкуемого модуля отличается от ABI текущей единицы трансляции. А ещё, вероятно, может помочь санитайзер с возможностью анализа внешних зависимостей.
Лично я считаю, что санитайзер — мастхэв для любого хоть немного большого проекта, написанного на С++. Но количество способов стрельнуть себе в ногу — бесчисленно, в связи с чем, у меня есть гипотеза, что просто технически невозможно написать такой санитайзер, который был бы в состоянии однозначно детектировать все возможные проблемы в программе. Правда это не означает, что санитайзер не может найти оптимальное количество проблем, стоящее его интеграции в проект.
В конце концов, код пишет программист, поэтому чем изначально меньше ошибок будет в коде, тем меньше ошибок будет в проде. Поэтому, давайте теперь перейдём к практическим советам.
2. Практика
Данную главу имеет смысл рассматривать последовательно. В ней я буду использовать следующий стартовый пример кода, который будет модифицироваться с каждым разобранным нюансом.
struct __declspec(dllexport) Person
{
Person(std::string name)
{
if(name.empty())
{
throw std::invalid_argument("name can't be empty");
}
}
/* some impl */
};
__declspec(dllexport) Person make_person(const char* name)
{
return Person{name};
}
Заметка: В рамках статьи я не буду рассматривать что именно делает __declspec(dllexport)
, но я должен отметить, что данная инструкция нужна здесь для того, чтобы сделать "видимыми" в другом программном модуле класс Person
и функцию make_person
. Без этой инструкции другой программный модуль не сможет вызвать эту функцию, потому что, с его точки зрения, такой функции не существует.
2.1. Не позволяйте исключениям переходить бинарную границу
Если вы выбрасываете исключение из одного программного модуля и перехватываете и обрабатываете это исключение в другом программном модуле, то можете получить UB, поскольку каждый компилятор может реализовывать механзим исключений по-своему.
Почему нельзя пропускать исключения через бинарную границу?
Сам механизм обработки исключений задекларирован в стандарте, но имплементация всё ещё является платформо-зависимой, стандартного лайаута для С++ исключений, к сожалению не существует.
Между моим и вашим практическим опытом может вознакать разница, потому что доказать, что UB нет — вцелом сложнее, чем доказать, что он есть. Мы можем видеть множество кейсов, в которых всё выглядит хорошо, но это не значит, что это не ведёт к UB.
Если же опираться на доказательную базу, то мы имеем следующее.
Например, существует спецификация Common Vendor ABI (Itanium C++ ABI), которую некоторые вендоры поддерживают в своих компиляторах.
Это попытка решить на уровне этой спецификации многие вопросы ABI совместимости, для которых стандарт оставил пространство для воображения.
По этому поводу даже был purposal WG21 N4028 Defining a Portable C++ ABI, но, судя по всему, он не был удовлетворён. В связи с чем, каждый компилятор горазд на свою имплементацию стандарта, в частности каждый сам решает хотят они поддерживать Common Vendor ABI (Itanium C++ ABI), или нет: GCC, к примеру, начиная с 3 версии, соответствует спецификации Itanium:
... Furthermore, C++ source that is compiled into object files is transformed by the compiler: it arranges objects with specific alignment and in a particular layout, mangling names according to a well-defined algorithm, has specific arrangements for the support of virtual functions, etc. These details are defined as the compiler Application Binary Interface, or ABI. From GCC version 3 onwards the GNU C++ compiler uses an industry-standard C++ ABI, the Itanium C++ ABI.
Но жизнь в GCC есть и до его третьей версии, поэтому, даже при использовании только GCC, но разных его версий, мы можем увидеть странное поведение при переходе бинарной границы из-за разницы имплементаций.
Что конкретно может приводить к бинарной несовместимости?
Согласно Itanium Level II: C++ ABI 2.1 Introduction, стандарт С++ не настоял на следующих деталях:
The second level of specification is the minimum required to allow interoperability in the sense described above. This level requires agreement on:
Standard runtime initialization, e.g. pre-allocation of space for out-of-memory exceptions.
The layout of the exception object created by a throw and processed by a catch clause.
When and how the exception object is allocated and destroyed.
The API of the personality routine, i.e. the parameters passed to it, the logical actions it performs, and any results it returns (either function results to indicate success, failure, or continue, or changes in global or exception object state), for both the phase 1 handler search and the phase 2 cleanup/unwind.
How control is ultimately transferred back to the user program at a catch clause or other resumption point. That is, will the last personality routine transfer control directly to the user code resumption point, or will it return information to the runtime allowing the latter to do so?
Multithreading behavior.
The layout of the exception object created by a throw and processed by a catch clause.
When and how the exception object is allocated and destroyed.
The API of the personality routine, i.e. the parameters passed to it, the logical actions it performs, and any results it returns (either function results to indicate success, failure, or continue, or changes in global or exception object state), for both the phase 1 handler search and the phase 2 cleanup/unwind.
How control is ultimately transferred back to the user program at a catch clause or other resumption point. That is, will the last personality routine transfer control directly to the user code resumption point, or will it return information to the runtime allowing the latter to do so?
Multithreading behavior.
Разница может появиться из-за разницы механизмов:
Раскрутки стека
Когда вылатает исключение происходит раскрутка стека, до тех пор, пока не будет встречен первый
catch
. Поскольку разные комиляторы могут использовать стек по разному, то огранизация стека может быть разной, что в свою очередь приведёт к тому, что во время обработки исключений два модуля, интерпретируя стек по разному, будут работать с его фреймами каждый по своим правилам.Вероятные последствия: нарушение целостности системного стека, UB с иногда стреляющим
SIGSEGV
/SIGBUS
.Создания и перехвата исключений
Механизм, используемый для создания и перехвата исключений (конструирвоание, копирование, перемещение, деструкция, выделение и освобождение памяти), может различаться в зависимости от компилятора.
Вероятные последствия: нарушение целостности памяти программы, UB с иногда стреляющим
SIGSEGV
/SIGBUS
.Лайаута исключений
Расположение объектов исключений в памяти (порядок полей, выравнивание, и т.д.) может различаться в зависимости от компилятора.
Вероятные последствия: UB с иногда стреляющим
SIGSEGV
/SIGBUS
.Менглинга имён
Поскольку ABI компиляторов может быть разным, они могут по-разному менглить и деменглить имена. У нас нет гарантий, наложенных С++ стандартом, что механизм менглинга имен будет одинаковым.
Вероятные последствия: в зависимости от имплементации, мы можем получить ODR violation и соответствующий UB.
Как с этим бороться?
Давайте рассмотрим возможные решения этой проблемы на стартовом примере кода:
struct __declspec(dllexport) Person
{
Person(std::string name)
{
if(name.empty())
{
throw std::invalid_argument("name can't be empty");
}
}
/* some impl */
};
__declspec(dllexport) Person make_person(const char* name)
{
return Person{name};
}
Как вы видите, код написан таким образом, что из make_person
может вылететь исключение, которое перейдёт бинарную границу, и, никому кроме вызывающей стороны (другого модуля) его уже не перехватить.
Доработку я бы начал с добавления try/catch
в точке, самой близкой к бинарной границе, при этом добавив пессимистичный, перехватывающий все исключения, catch(...)
чтобы гарантировать, что ни одно исключение точно не просочится в другой модуль:
__declspec(dllexport) Person make_person(const char* name) noexcept
try
{
return Person{name};
}
catch(...)
{
// todo: what should we do next?
}
Теперь у нас есть try/catch
(выглядит он немного специфично, потому что это fucntion try block
) и мы должны придумать как нам обработывать ошибки, чтобы мы могли сигнализировать о них модулю, делающему вызов функции. Сделать это можно очень по-разному и всё зависит от конкретной ситуации и от того насколько допустимо в вашем конкретном кейсе передавать структуры или указатели через бинарную границу.
Давайте рассмотрим несколько популярных вариантов сигнализации об ошибках.
2.1.1. Передача кода ошибки через out аргумент функции и возврат объекта по значению (и наоборот)
Почему нужно использовать коды ошибок?
С моей точки зрения, выбор между коды ошибок vs исключения — дискуссионная тема. Каждый из подходов имеет свои минусы и плюсы.
Например, исключение нельзя проигнорировать, а код ошибки можно (даже если указать[[nodiscard]]
аттрибут). С другой стороны, механизм раскрутки стека (stack unwind) работает медленнее, чем может работать обработка кодов ошибок. А ещё в некоторых ситуациях мы просто не можем вернуть код ошибки, например из конструктора (технически можем, через аргумент ссылку, но это уже экзотика).
Но в ситуации с переходом бинарной границы мы не можем использовать исключения, потому что способ их обработки зависит от ABI.
При этом, если вы используете готовую абстракцию, описывающую ошибку, будте аккуратны с этим, поскольку готовая абстракция может быть несовместима между разными версиями библиотеки, в которой она находится (конечно если библиотека не гарантирует обратной совместимости между своими версиями).
Если же вы реализуете класс ошибки самостоятельно, то просто учтите этот нюанс.
Например, в версии 1.0 описание ошибки может быть таким:enum class Result { Ok, Error };
, а в версии 2.0 может добавиться маленькое изменение:enum class Result { Ok=1, Error }
, которое приведёт к breaking change в протоколе.
И да помогут вам все боги мира, если вы десериализировали этот enum
как-то так: Result result = static_cast<Result>(raw_result); .
Потому что в этом случае, если raw_result==0
, то и у result
будет значение 0
, которого даже нет в новой версии этого enum
-a.
Один из способов передачи информации об ошибки, это передача ссылки на код ошибки в функцию, и возврат инстанса Person
:
__declspec(dllexport) Person make_person(
const char* name,
boost::system::error_code& result) noexcept
{
using namespace errc = boost::system::errc;
try
{
Person person{name};
result = errc::make_error_code(errc::success);
return person;
}
catch(...)
{
result = errc::make_error_code(errc::invalid_argument);
return Person{};
}
}
Я не написал return std::move(person);
чтобы обратить внимание на возможные оптимизации, которые могут быть применены компилятором. Может показаться, что плюс такого решения — то, что компилятор может применить copy elision оптимизацию: RVO и NRVO, что позволит избавиться от лишнего вызова перемещающего конструктора. Но, в случае линковки с библиотекой, в которую этот объект передаётся, появляется барьер оптимизации, поскольку код библиотеки фиксирован и полностью собран.
Почему я не рекомендую рассчитывать на copy elision в данном случае перехода бинарной границы?
Теоретически, copy elision при переходе бинарной границы возможен, если (список приближённый и всё может варьироваться в зависимости от конкретной имплементации компилятора):
Для компиляции модулей используется один и того же компилятор.
И окружение при компиляции модулей одно и то же.
И вы используете одни и те же параметры компиляции.
И разработчики компилятора поддержали такую возможность.
Поэтому, с некоторой вероятностью, если вы решите поэкспереминтировать самостоятельно, то вы можете увидеть работающий RVO/NRVO в данном примере. Например, у @KanuTaH получилось этого добиться (см. этот комментарий).
Но основная причина успеха такого экспиримента будет скрываться в том, что это частный случай, при использовании одной и той же платформы, с одним и тем же компилятором и одними и теми же параметрами окружения.
Почему так?
Во-первых,
Copy elision, как и любая другая опитимизация, описан в стандарте только своими эффектами (то что должно произойти с уровня абстракции языка), но стандарт не регламенитрует конкретную реализацию (то как имплементация конкретно должна это делать).
С этой точки зрения, два произвольно выбранных компилятора могут иметь разную реализацию. Да, количество путей, которыми можно сделать эту оптимизацию — строго ограничено, но в деталях она может отличаться. Эта разница в деталях может сделать эту оптимизацию невозможной для двух разных модулей в общем случае. Ситуация получается аналогичной тому, как детали реализации механизма vtable
, рассмотренного в статье, приводят к тому же эффекту нарушения бинарной совместимости.
В частности,
Вызывающий функцию модуль, как и модуль, содержащий реализацию функции, всегда может быть собран со стандартом меньшим, чем С++17, например С++11/C++14. Поэтому компилятор, при сборке этого модуля, согласно стандарту С++11 и C++14, не обязан поддерживать copy elision.
Поэтому:
В общем случае, это implementation defined поведение, на которое я бы не стал завязываться, если вас интересует вопрос совместимости двух, потенциально собранных разными инструментами и с разными параметрами, модулей.
Данные оптимизации возможны между разными единицами трансляции (объектными файлами) при их сборке и ликовке, если вы используете Link-Time-Optimization и Link-Time-Code-Generation. Но они невозможны между разными программными модулями.
К тому же, в данном случае, нам придётся использовать в Person
дефолтный конструктор, что добавляет ещё одно состояние в класс: неинициализированное. Чтобы быть параноидально уверенным в соблюдении инвариантов и контрактов, такое состояние придётся отдельно обрабатывать в каждом методе класса, что усложнит его логику, поэтому лучше избежать этого. Понятно, что дефолтный конструктор в данном примере кода может генерироваться автоматически, но количество кейсов, в которых он нам действительно нужен — строго ограничено. В реальности мы помечаем конструктор delete
или просто не хотим добавлять в класс ещё одно, неинициализированное состояние.
2.1.2. Возврат optional<T>
Частично, проблемы с возвратом объекта по значению из функции можно решить оборачиванием этого объекта в optional
и in-place инициализацией этого optional
(через in_place
тег). Но, стоит учитывать, что вне зависимости от стандарта языка (С++14 или С++17 и выше) это приносит несколько, иногда неприятных, дополнительных требований к реализации кода класса, обёрнутого в optional
(иначе мы не сможем вернуть его в другой модуль):
У класса
T
изoptional<T>
должна быть реализована семантика перемещения.Или у класса
T
изoptional<T>
должна быть реализована семантика копирования.
Насколько вы могли понять, такое ограничение появляется потому что бинарная граница — барьер оптимизации, поэтому при её переходе не применимы copy elision оптимизации RVO и NRVO (см. более подробное описание в уточнении "Почему я не рекомендую рассчитывать на copy elision в данном случае перехода бинарной границы?").
С другой стороны, проблемы, описанные выше, можно обойти, если хранить объект через указатель, например так: optional<std::unique_ptr<Person>>
. Но такая конструкция получается беспричинно переусложнённой по сравнению с sid::unique_ptr<Person>
. В связи с чем, давайте попробуем найти более идеальный способ, следующий на очереди — возврат указателя на объект.
2.1.3. Возврат указателя на объект
Ещё один вариант сигнализировать об ошибке — возвращать указатель на объект. А, если произошла ошибка, возвращать nullptr
.
Для достижения этой цели я не рекомменую использовать сырые указатели, потому что в мире С++ уже давно не нужно управлять памятью вручную. На помощь в этом вопросе нам приходит идиома RAII, и умные указатели из STL:
__declspec(dllexport) std::unique_ptr<Person> make_person(const char* name) noexcept
try
{
return std::make_unique<Person>(name);
}
catch(...)
{
return nullptr;
}
Что может пойти не так?
Сложно сказать сразу, не копаясь в механизмах, которые С++ использует для реализации метапрограммирования. Но ответ тесно связн с механизмои инстанциирования шаблонов.
Умные указатели из стандартной библиотеки — шаблоны классов, использующие специальную сущность — deleter
, который содержит логику по созданию и удалению объекта. Чаще всего логика захвата и освобождения — стандартная:
Создание объекта
operator new
Выделение памяти
T* p = static_cast<T*>(std::malloc(objects_count * sizeof(T));
Вызов конструктора (a.k.a in-place new)
new(p) T(std::forward(args)...);
Удаление объекта
operator delete
Вызов деструктора
p->~T();
Освобождение памяти
std::free(p);
Данный стандартный алгоритм создания и удаления объекта реализован в выбираемом умными указателями по умолчанию deleter`е — std::default_delete<T>
. Насколько вы видите, это тоже шаблон класса от параметра T
.
Одна из составляющих, обеспечивающих механизм шаблонов в C++, это механизм инстанциирования шаблона. Говоря кратко, он проходит по всем уже инстанциированым шаблонам, и, если шаблона под нужный тип ещё не инстанциировано, то он его инстанциирует, иначе берёт уже готовый. Каждый инстанциированный шаблон хранит логику по работе с конкретным типом.
Теоретически, на один и тот же шаблонный класс в исполняемом файле A.exe
и библиотеке B.dll
может быть по два инстанциированных шаблона на один и тот же тип. Это означает некоторую дубликацию логики между модулями, что может быть критично, если два модуля по разному выделяют и освобождают память и констурируют объекты. Такое может произойти при нарушении правила одного определения (ODR violation), и это может касаться написанного вами кастомного deleter`а.
Поэтому дополнительные советы, которые хочется дать:
Следите за своими шаблонами, и не допускайте ошибки ODR violation.
Удостоверяйтесь в том, что вы вызываете деструктор и освобождаете память объекта из того же модуля, из которого были захвачена память и вызван конструктор.
2.1.4. Возврат boost::leaf::result
Среди библиотек Boost существует Boost.Leaf. Я нашёл эту библиотеку довольно удобной для обработки ошибок. Я бы сказал, что она даёт опыт работы с ошибками, похожий на работу с крейтом std::result
из Rust, который мне тоже нравится.
Для возврата значения из функции, в которой может произойти ошибка используется boost::leaf::result
. С точки зрения обработки ошибок, принципиальное отличие boost::leaf::result
от std::optional
и boost::optional
в том, что:
boost::leaf::result
более безопасно спроектирован.
Например, если в нём нет объекта, тоoperator*
выбросит исключение. В отличие от реализацииoperator*
вoptional
, которая в случае отсутствия объекта приведёт к UB (если отключеныassert
-ы в релизе).Существует довольно востребованная специализация
boost::leaf::result<void>
для функций, которые ничего не возвращают.В
boost::leaf::result
реализована расширенная работа с ошибками, позволяющая не просто сигнализировать о наличии ошибки, а так же предоставить дополнительную информацию о ней (локация где ошибка появилась, юзер-тип для описания ошибки и т.д.).
Применить этот класс в нашем случае можно следующим образом: boost::leaf::result<std::unique_ptr<Person>>
. При этом, по описанным выше причинам, эта конструкция имеет намного больше смысла, чем optional<std::unique_ptr<Person>>
, описанная до этого.
__declspec(dllexport) boost::leaf::result<std::unique_ptr<Person>> make_person(const char* name) noexcept
try
{
return std::make_unique<Person>(name);
}
catch(const std::exception& e)
{
return boost::leaf::new_error(e.what());
}
catch(...)
{
return boost::leaf::new_error("unexpected exception caught");
}
2.2. Конструирование объекта
Мы хотим, чтобы объект Person
создавался только через фабричную функцию, чтобы гарантировать соблюдение нужных нам, разработчикам класса Person
контрактов. В частности, мы хотим чтобы:
Этот объект вернётся по указателю.
deleter
, используемыйunique_ptr
, будет точно инстанциирован в том же модуле, в котором объект был инициализирован.
Но сейчас пользователь может сконструировать Person
из другого модуля, поскольку у этого класса есть публичный конструктор. Понятно, что можно обговорить на словах контракт, что так делать не стоит, но мы можем просто запретить так делать, пометив коструктор приватным и заfriend
`див фабричную функцию.
При этом нам нужно учесть, что, согласно правилу пяти / трёх (см. "Правила Трех, Пяти и Ноля" от @MaxRokatansky), при соблюдении некоторых условий, компилятор может автоматически генерировать следующий код:
Конструкторы копирования и перемещения.
Копирующий и перемещающий операторы присвоения.
Конструктор по умолчанию.
Я рекомендую в любой ситуации, когда вам не нужны эти автогенерируемые члены, явно их удалять, а если вы подумали, что они вам нужны, то чаще бывает лучше передумать. Несчётны те ошибки, которые были и будут порождены случайным копированием или перемещением. Последствия могут быть так же печальны, если класс не был спроектирован так, чтобы учитывать конструирование через сгенерированный конструктор "по умолчанию".
В нашей ситуации, чтобы избежать генерации всех этих автогенерируемых членов класса, нам достаточно cделать класс "некопируемым", пометив коструктор копирования и оператор копирования = delete
.
2.3. Суровая реальность
Дойдя до этого пункта вы видели разные варинаты сингализации об ошибке: возврат optional
, boost::leaf::result
, unique_ptr
(и иных умных указателей). Я сказал, что каждый из этих способов позволяет митигировать возникновение проблем с конкретным аспектом ABI совместимости (сигнализации об ошибках), но не гарантирует ABI совместимости всей программы вцелом.
Но правда ли предложенные мной подходы позволяют искоренить проблему?
Вопрос: Насколько безопасно, с точки зрения бинарной совместимости, пердавать библиотечные объекты вроде optional
, boost::leaf::result
, unique_ptr
между модулями?
Ответ: Это не безопасно.
Во-перых, все эти типы — пользовательские классы из разных библиотек, имплементация которых зависит от реализации этой библиотеки под конкретную платформу и компилятор. То есть даже в при использовании одной и той же версии библиотеки, но на разных компиляторах, имплементация одной и той же сущности может отличаться.
Во-вторых, детали механизмов конструирования (вызов конструктора) и деструкции (вызов деструктора) объекта, тоже могут меняться в зависимости от компилятора. В результате может получиться так, что правила по которым объект был сконструирован будут отличаться от правил, по которым он будет разрушен. Последствия этого эффекта могут быть ужасны, потому что они нарушают целостность внутренних механизмов работы программы, заложенных компилятором, которые, по мнению компилятора, не могут нарушаться.
Единственное, что мы можем передать через границу, это объекты POD (Plain Old Data) типов, потому что они не содержат конструктора и деструктора.
Что такое POD типы? Зачем они нужны и как их идентифицировать средствами STL?
Примеры POD типов: встроенные арифметические типы, включая (char
и bool
), перечисления, указатели, и пользовательские POD типы.
Что такое пользовательские POD-типы?
Если коротко, то это типы, которые не содержат в себе управляющего кода, связанного с процессом конструирования объекта, и не имеют базовых классов.
Более подробно, чтобы пользовательский тип оказался POD-типом, он должен:
Не иметь следующих методов:
Конструкторов
Деструкторов
Операторов присваивания
Не иметь базовых классов
Не иметь виртуальных функций
Каждый нестатический член-данные этого POD-типа должен:
Иметь
public
спецификатор доступаБыть POD-типом
По описанию может быть сложно понять, как POD-тип выглядит в настоящей природе, поэтому, для наглядности можно взглянуть на фрагмент кода:
struct PodType
{
char c;
int i;
char str[10];
};
Зачем нужны POD типы?
POD типы были введены чтобы разделить работу со старыми типами языка Си и новыми в С++. При работе с POD типами известно их размещение в памяти, а так же работают все связанные механизмы из Си.
Для не POD-типа нельзя сделать практически никаких предположений о том, как устроен объект в памяти. Внутри такого объекта в относительно произвольных местах могут располагаться служебные области, которые компилятор использует для своих целей (например для выравнивания структур данных). Не POD-типы позволяют разработчикам компилятора организовывать новые типы по их усмотрению, из стандартных соображений эффективности реализации и удобности их сопровождения.
Как идентифицировать POD
типы средствами STL?
В стандартной библиотеке есть type trait, позволяющий это сделать: std::is_pod<T>
.
Более глубокое рассммотрение этой темы выходит за рамки статьи. Но, если вам интересно поглубже разобраться в теме, то вы можете продолжить, поняв что такое standard layout type.
В связи с этим, передача пользовательских типов без нарушения бинарной совместимости, в общем случае, просто невозможна. Поэтому, если вы хотите возвращать из экспортированной функции в другой модуль optional
, boost::leaf::result
, unique_ptr
, или любой другой пользовательский тип, то вам нужно предварительно методично исследовать совместимость реализаций используемой библиотеки (будь это STL, boost или что-либо другое), а так же имплиментаций компиляторов (если используются разные компиляторы, или разные версии одного и того же).
То есть в нашей стуации мы не можем передать объект типа Person
через бинарную границу по значению. Мы можем вернуть сырой указатель на него, но это означает, что нам нужно будет вручную управлять захватом и освобождением памяти со стороны другого модуля. Кроме того, мы должны будем удостовериться, что вызов деструктора и освобождение памяти будут произведены именно тем модулем, который выделил память и вызвал конструктор.
Можем ли мы сделать Person POD типом?
Да, мы можем превратить Person
в POD тип, потому что:
Согласно текущему интерфейсу
make_person
, для инициализацииPerson
используется сырой указательconst char*
, который является POD типом.POD тип может быть композицией из членов-данных других POD типов (пусть
Person
и будет "композировать" только один сырой указатель).
Но, если мы заменим тип поля Person::name
на указатель, то тогда нам нужно будет удостоверяться в том, что он всегда указывает на валидную область памяти, содержащую имя. То есть нам нужно самостоятельно митигировать риски висячего указателя, потому что сырой указатель не гарантирует того, что объект, на который он указывает всё ещё жив (например, если с другой стороны имя хранится в std::string
и передаётся так: make_person(name.c_str())
).
В качестве альтернативного решения, мы можем заменить Person::name
на Си-стайл массив, предварительно заполнив его null-termination '\0'
символами (чтобы упростить его заполенние в конструкторе, ведь если мы будем в будущем использовать этот член класса как Си-стайл строку, то у неё есть требование оканчиваться на '\0'
), в результате получится что-то вроде буффера (поскольку не все байты в нём могут использоваться). Но в таком случае мы должны будем следить за тем, чтобы не произошло переполнения буффера при заполнении этого массива. А так же мы сталкиваемся со следующей проблемой: мы не знаем реального размера имени на этапе компиляции, при этом нам надо указать какой-то размер на этапе компиляции, потому что этого требует объявление Си-стайл массива. Если мы выберем размер имени не слишком большим, например 15, то ваш сервис сломается на вервом же Хьюберте Блейн Вольфшлегельштайнхаузенбергедорфе-старшем, если же слишком маленьким, то у нас с высокой вероятность будет простаивать лишнее количество байт, что не совместимо с терменим memory efficiency (один из основополагающих факторов почему для разработки вообще решили использовать С++).
К тому же, это решение с Си-стайл массивом — плохо масштабируемое, например, если бы Person::name
инициализировался не строкой, а другим, более сложным не POD-типом, то перед нами встанет довольно нетривиальная в своих деталях задача по "превращению" инстанса не-POD-типа в POD-тип.
3. Финальный пример кода (игнорирующий суровую реальность)
Постаравшись учесть все эти нюансы, я прихожу к следующему решению:
struct Person
{
private:
Person(std::string name)
{
if(name.empty())
{
throw std::invalid_argument("name can't be empty");
}
}
Person(const Person&) = delete;
Person& operator=(const Person&) = delete;
public:
friend __declspec(dllexport) boost::leaf::result<std::unique_ptr<Person>> make_person(const char* name) noexcept;
/* some impl */
};
__declspec(dllexport) boost::leaf::result<std::unique_ptr<Person>> make_person(const char* name) noexcept
try
{
return std::make_unique<Person>(name);
}
catch(const std::exception& e)
{
return boost::leaf::new_error(e.what());
}
catch(...)
{
return boost::leaf::new_error("unexpected exception caught");
}
В одном чёрном-чёрном доме ...
На черном-черном острове, в самую черную-черную ночь, под черным-черным дубом покоился черный-черный дом. В нём стоял черный-черный стол, на нем лежал черный-черный гроб с гравировкой: "ABI совместимость С++".
Насколько вы поняли, реальность ABI совместимости в C++ довольно сурова. Ещё более суровым её делает тот факт, что только при использовании Си API решение становится по-настоящему "бинарно-дружелюбным". Но, в то же время, со всем моим уважением к Си, код потеряет ту маленькую долю лакончиности и удобства, которое даёт нам С++, хотя, кажется, в тех редких случаях когда нам нужна 100% совместимость, другого выбора у нас нет. Если же всё-таки вы решились на это, то сделайте Си API, после чего напишите header-only врапперы на C++.
Вы всегда можете написать Си API для своего юзкейса, перейти на использование POD типов, но на самом деле наилучший совет для перехода бинарной границы модулей с разным ABI — не переходить её. Чаще всего игра не стоит свеч (и в очередной раз отстреленных ног), поэтому проще использовать header-only
версию библиотеки, если она есть. Или же пересобрать библиотеку под свой тулсет, или найти уже пересобранную.
Спасибо вам, за уделённое время, надеюсь данная статья была для вас полезна. Если у вас возникли вопросы, вы заметили ошибку или у вас есть предложение по улучшению статьи, то я буду рад любой обратной связи в ЛС.
Избегайте остроумия и HolyHandGrenade
, всем KISS.

Отдельная благодарность моему товарищу @f101v за помощь в поиске и устранении ошибок в статье.
UPD:
15.10.23:
Добавил разьяснение про то, почему при переходе бинарной границы не стоит рассчитывать на copy elision. Ссылка на уточнение тут. Спасибо @KanuTaH, что обратил внимание на эту неточность!
17.10.23:
Добавил новый пункт про POD-типы: 2.3. Суровая реальность.
Обновил заключение: В одном чёрном-чёрном доме ...
18.10.23:
Добавил в пункт 2.1. Не позволяйте исключениям переходить бинарную границу подробный анализ причин почему не стоит пропускать исключения через бинарную границу. Спасибо @grumegargler, что обратил внимание на это внимание!