Глубина кролечьей норы: бинарная граница и ABI C++

Оценивайте свои силы трезво

Оценивайте свои силы трезво

Мир С++ многое сокрывает от глаз программиста. Это свойство языка — обоюдоострый меч, который с одной стороны позволяет уменьшить порог входа, но в то же время является препятствием при написании программ, уходящих дальше стандартного примера.

В первой части статьи я поделюсь теоретическими знаниями о том, что такое ABI и бинарная граница, и какие проблемы могут возникнуть при её пересечении.

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

В рамках всей статьи будут рассмотрены темы: системный стек, системные регистры, динамическая память, детали механизма виртуальных функций, стандарт С++ и реальная имплементация, copy elision при линковке библиотек, шаблоны и ODR violation.

Оглавление

1. Необходимая теория

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

Под программным модулем я понимаю некоторый артефакт, который мы получаем после компиляции нашей программы, вроде: статической/динамической библиотеки, или исполняемого файла.

Когда я говорю программный модуль в этой статье, я не подразумеваю поддержку модулей из C++20.

1.1. Что такое бинарная граница

В моём понимании, это место, находящееся на границах инструкций двух программных модулей.

Возможно, проще понять что такое бинарная граница, поняв когда мы её переходим. К примеру, переход бинарной границы происходит в тот момент, когда наш поток, исполняющий код файла a.cpp, который был скомпилярован в исполняемый файл a.exe, вызывает функцию, определённую в файле b.cpp, скомпилированную в библиотеку b.dll (или b.lib), которую исполняемый файл:

  1. Загрузил уже после своего запуска (в случае динамической линковки, т.е. использования динамической библиотеки .so/.dll),

  2. Или же эта библиотека была встроена в исполняемый файл на этапе компиляции этого исполняемого файла (в случае статической линковки, т.е. использования статической библиотеки .a/.lib),

  3. А ещё есть комбинированный вариант, когда у нас есть статическая библиотека, предоставляющая таблицу экспорта символов из динамической библиотеки.

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

1.2. Что такое ABI

Компилятор, при сборке модуля, определяет для программы свои правила, и от компилятора к компилятору они могут различаться. Эти правила называются Application Binary Interface (ABI) приложения. На практике к ABI относится:

  1. Способ взаимодействия программы с ОС

  2. Реализация базовых механизмов языка:

    1. Реализация механизма исключений.

    2. Конвенция вызовов методов.

    3. Реализация динамического полиморфизма (a.k.a. полиморфизма подтипов, a.k.a. «механизма виртуальных функций»).

    4. Реализация виртуального наследования.

  3. и так далее…

Если же спуститься на более низкий уровень абстракции, к более базовым механизмам языка, то ABI описывает:

  1. Способы использования регистров процессора.
    Говоря иначе, тут декларируется в каком регистре что храниться и куда что записывать:

    1. где хранится возвращаемое значение из функции

    2. где и в каком порядке хранятся значения, переданные в функцию

    3. и так далее.


    На самом деле, именно мы, как программисты, определяем обязанности регистров. В программировании на С++ мы, зачастую (опуская ассемблерные вставки), не имеем дела с регистрами напрямую.
    Всё благодаря тому, что другие программисты — разработчики компилятора, уже реализовали все нужные абстракции, поддержали один из стандартов С++ в своём компиляторе, чтобы мы не разбирались с тем как использовать такие низкоуровневые абстракции как регистры.

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

    Стоит упомянуть, что область применения регистров немного пересекается с тем, зачем используется системный стек, о котором я говорю следующем пункте.

  2. Организацию системного стека.
    Эта структура данных хранится в оперативной памяти программы. Системный стек полезен нам тем, что:

    1. Системный стек позволяет нам хранить значения внутри контекста (например, функции, или блока ограниченного фигурными скобками {}).
      Когда вы создаёте переменную в программе, её значение сохраняется в текущем кадре стека. При вызове функции в стек добавляется новый кадр. При выходе из функции, текущий кадр удаляется со стека, вместе со связанными значениями переменных.

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

    3. Состемный стек ограничивает время жизни локальных переменных, разрушая их при выходе из контекста, ограниченного фигурными скобками {}.

    Упрощённый пример: локальные переменные из текущей функции a() сохраняются на кадре стека с id==0, и, при вызове функции b() из функции a(), в стек помещается новый, «чистый» от локальных переменных кадр с id==1. При возврате из функции b(), этот новый кадр с id==1 удаляется из стека, тем самым оставляя в стеке только исходный кадр с id==0 (со значениями функции a). При удалении кадра стека вызываются деструкторы для всех локальных переменных, находящемся на этом кадре.

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

    Один из подвозных камней системного стека — его размер фиксирован и не изменяется со временем работы программы. Это означает, что если мы будем бесконечно делать рекурсивные вызовы, то это приведёт к ошибке, называемой stack overflow (переполнение стека), название которой было взято за основу одноименного сайта, помогающего всем страждющим.

  3. Работу с динамической памятью (диспетчером памяти ОС).
    Чтобы дать программисту возможность использовать больше оперативной памяти, чем может вместить системный стек, была придумана такая абстракция, как динамическая память, доступ к которой осуществляется через диспетчера памяти ОС.

    Важно понимать, что ОС нарочно делает вид, будто бы у неё есть бесконечное количество памяти, которую процесс нашей программы может запросить. Используя различные инструменты, вроде виртуальной памяти, дефрагментации памяти и т.п.

    Но на самом же деле количество оперативной памяти в ОС строго ограничено. На моём опыте, ситуация, в которой программе не хватает свободного места, чаще всего не обрабатывается в современных, не очень требовательных к отказоустойчивости, программах. В результате, вполне ожидаемый исход такого «голодания» по ресурсу динамической памяти — падение процесса, которому этой памяти не хватило.

    Вцелом, 8-ми гигабайт ОЗУ обычно достаточно, чтобы среднестатистическая программа смогла работать без перебоев. Но если его недостаточно, то вы всегда можете просто докупить ещё больше ОЗУ и вставить новую плашку оперативки в материнскую плату, либо увеличить размер swap file. Если только ваша программа не содержит утечек памяти, потому что иначе, сколько бы памяти у вас не было, она вся утечёт. В частности, поэтому важно не допускать утечек памяти.

  4. и так далее…

1.3. Стандарт C++ и ABI

На этом этапе, вы, возможно, могли задаться вопросом:

Разве стандарт С++ не фиксирует единый ABI для всех компиляторов?

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

Например, стандарт не обязывает реализовывать виртуальные функции именно через таблицу виртуальных функций (a.k.a. vtable).

Что такое виртуальные функции и зачем они нужны?

Виртуальные функции — механизм, необходимый для функционирования полиморфизма подтипов, так же известного как динамическая диспетчеризация вызовов функций. С точки зрения конструкций языка, этот механизм прячется за ключевым словом virtual и реализацией наследования классов.

То есть это вируальные функций, это тот самый базис, который позволяет нам описать и использовать единый интерфейс для нескольких сущностей, при этом не прибегая к метапрограммированию (a.k.a. обобщённому программированию/шаблонам).

Классический учебный пример задачи, которую можно решить с помощью виртуальных функций и интерфейсов: в вашей игре надо реализовать поведение разных уток (fly, quack, swim), в бизнес-требованиях обозначены следующие виды:

  1. Утка обыкновенная.

  2. Утка-мандаринка.

  3. Охотничья утка-приманка.

Суть проблемы сводится к тому, что поведение у каждого из типов уток — разное. Все утки умеют крякать, но то как крякает обычная утка отличается от того как крякает охотничья утка-приманка и утка-мандаринка. А так же все утки умеют плавать, но, как вы уже поняли, утка-приманка просто плывёт по течению. По понятным причинам утка-приманка сможет полететь только если её снабдить рактивным ранцем.

А ещё в бизнес-требованиях сказано, что в будущем вероятно появятся большое количество новых видов уток со своим уникальным поведением. Это важный нюанс, потому что вы, как программист этой логики, можете подготовить почву для других программистов (или вас же), чтобы расширение этой логики другими утками происходило быстрее, занимало меньше трудозатрат.

Обсуждение решения выходит за рамки этой статьи, но, вкратце, в качестве решения можно:

  1. Создать интерфейс IDuck, в котором объявить все методы (fly, quack, swim), после чего overload-нуть (перегузить) эти методы в классах потомков (SimpleDuck, MandarinDuck, DummyDuck).

  2. Или же, можно использовать паттерн проектирования strategy (стратегия), и сделать на каждый из методов (fly, quack, swim) по отдельному интерфейсу (IFlying, IQuacking, ISwimming) со своей реализацией.

И даже если два компилятора будут использовать таблицу виртуальных функций под капотом, то порядок хранения указателей в этой таблице может отличаться. Что в свою очередь может привести к тому, что один модуль будет вызывать первую функцию в vtable со своей стороны, а у другого модуля эта функция является последней в vtable. Таким образом, мы неминуемо столкнёмся с UB или SIGSEGV (сигнал, который вырабатывается программой в случае неверного обращения с памятью), или любым иным прекрасным творением мира С++ (и не только), так упорно пытающимся подавить нашу волю к жизни.

Ключевая идея, которую стоить понять, что ABI, и в целом логика работы базовых механизмов языка, может различаться:
1. В разных компиляторах.
2. И даже в рамках разных версий одного и того же компилятора.
3. И даже в рамках одной версии одного компилятора, но с разными флагами компиляции.

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

Если раньше вы ничего не слышали про ABI, то это не беда. Так получилось, потому что эта информация была скрыта намеренно.
С одной стороны, сокрытие информации о реализации ABI имеет свои плюсы, среди которых я вижу важным то, что он уменьшает порог входа в программирование на C++, который и без того большой.
А с другой стороны, когда мы говорим про взаимодействие двух программных модулей, если не отдавать себе отчёт о совместимости их бинарного интерфейса, то можно получить UB. Программа с неопределённым поведением обычно выглядит как чёрный цилиндр, из которого на тебя с некоторой вероятностью может выпрыгнуть тиранозавр (ну или SIGSEGV), что немного отбивает желание к жизни.

Если вглядываться в UB слшиком часто, то можно начать выпивать и в конце концов даже перейти на Rust

Если вглядываться в UB слшиком часто, то можно начать выпивать и в конце концов даже перейти на Rust

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{std::move(name)};
}

2.1 Не позволяйте исключениям переходить бинарную границу

Если вы выбрасываете исключение из одного программного модуля и перехватываете и обрабатываете это исключение в другом программном модуле, то можете получить 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{std::move(name)};
}

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

Доработку я бы начал с добавления try/catch в точке, самой близкой к бинарной границе, при этом добавив пессимистичный, перехватывающий все исключения, catch(...) чтобы гарантировать, что ни одно исключение точно не просочится в другой модуль:

__declspec(dllexport) Person make_person(const char* name) noexcept
try
{
  return Person{std::move(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(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, что позволит избавиться от лишнего вызова перемещающего конструктора. Но, в случае линковки с библиотекой, в которую этот объект передаётся, появляется барьер оптимизации, поскольку код библиотеки фиксирован и полностью собран.

Данные оптимизации возможны между разными единицами трансляции (объектными файлами) при их сборке и ликовке, если вы используете Link-Time-Optimization и Link-Time-Code-Generation. Но они невозможны между разными программными модулями.

К тому же, в данном случае, нам придётся использовать в Person дефолтный конструктор, что добавляет ещё одно состояние в класс: неинициализированное. Чтобы быть параноидально уверенным в соблюдении инвариантов и контрактов, такое состояние придётся отдельно обрабатывать в каждом методе класса, что усложнит его логику, поэтому лучше избежать этого. Понятно, что дефолтный конструктор в данном примере кода всёравно генерируется автоматически, но этот пример — синтетический. В реальности же количество кейсов, в которых нам нужен коснтуктор по умолчанию — строго ограничено. В реальности мы помечаем конструктор delete или просто не хотим добавлять в класс ещё одно, неинициализированное состояние.

2.1.2 Возврат optional

Частично, проблемы с возвратом объекта по значению из функции можно решить оборачиванием этого объекта в optional и in-place инициализацией этого optional (через in_place тег). Но, стоит учитывать, что вне зависимости от стандарта языка (С++14 или С++17 и выше) это приносит несколько, иногда неприятных, дополнительных требований к реализации кода класса, обёрнутого в optional (иначе мы не сможем вернуть его в другой модуль):

  1. У класса T из optional должна быть реализована семантика перемещения.

  2. Или у класса T из optional должна быть реализована семантика копирования.

Насколько вы могли понять, такое ограничение появляется потому что бинарная граница — барьер оптимизации, поэтому при её переходе не применимы copy elision оптимизации RVO и NRVO. Ну, а в случае использования С++14 в связке с boost::optional, RVO и NRVO оптимизации могут не поддерживаться компилятором, в зависимости от компилятора и конкретного кода.

С другой стороны, проблемы выше с optional можно обойти, если хранить объект через указатель, например так: optional>. Но такая конструкция получается переусложнённой, например, при сравнении с sid::unique_ptr. В связи с чем, давайте обсудим этот способ — возврат указателя на объект.

2.1.3 Возврат указателя на объект

Ещё один вариант сигнализировать об ошибке — возвращать указатель на объект. А, если произошла ошибка, возвращать nullptr.

Для достижения этой цели я не рекомменую использовать сырые указатели, лучше подойдут умные указатели из стандартной библиотеки:

__declspec(dllexport) std::unique_ptr make_person(const char* name) noexcept
try
{
  return std::make_unique(std::move(name));
}
catch(...)
{
  return nullptr;
}

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

  1. Создание объекта operator new

    1. Выделение памяти
      T* p = static_cast(std::malloc(objects_count * sizeof(T));

    2. Вызов конструктора (a.k.a in-place new)
      new(p) T(std::forward(args)...);

  2. Удаление объекта operator delete

    1. Вызов деструктора
      p->~T();

    2. Освобождение памяти
      std::free(p);

Данный стандартный алгоритм создания и удаления объекта реализован в выбираемым умными указателями по умолчанию deleter`е — std::default_delete. Насколько вы видите, это тоже шаблон класса.

Одна из состовляющих, обеспечивающих механизм шаблонов в C++, это механизм инстанциирования шаблона. Говоря кратко, он проходит по всем уже инстанциированым шаблонам, и, если шаблона под нужный тип ещё не инстанциировано, то он его инстанциирует, иначе берёт уже готовый. Каждый инстанциированный шаблон хранит логику по работе с конкретным типом.

Теоритически, на один и тот же шаблонный класс в исполняемом файле A.exe и библиотеке B.dll может быть по два инстанциированных шаблона на один и тот же тип. Это означает некоторую дубликацию логики между модулями, что может быть критично, если два модуля по разному выделяют и освобождают память и констурируют объекты. Такое может произойти при нарушении правила одного определения (ODR violation), и это может касаться написанного вами кастомного deleter`а.

Поэтому дополнительные советы, которые хочется дать:

  1. Не допускайте ошибки ODR violation.

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

2.1.4 Возврат boost: leaf: result

Среди библиотек Boost существует Boost.Leaf. Я нашёл эту библиотеку довольно удобной для обработки ошибок. Я бы сказал, что она даёт опыт работы с ошибками, похожий на работу с крейтом std::result из Rust, который мне тоже нравится.

Для возврата значения из функции, в которой может произойти ошибка используется boost::leaf::result. С точки зрения обработки ошибок, принципиальное отличие boost::leaf::result от std::optional и boost::optional в том, что:

  1. boost::leaf::result более безопасно спроектирован.
    Например, если в нём нет объекта, то operator* выбросит исключение. В отличие от реализации operator* в optional, которая в случае отсутствия объекта приведёт к UB (если отключены assert-ы в релизе).

  2. Существует довольно востребованная специализация boost::leaf::result для функций, которые ничего не возвращают.

  3. В boost::leaf::result реализована расширенная работа с ошибками, позволяющая не просто сигнализировать о наличии ошибки, а так же предоставить дополнительную информацию о ней (локация где ошибка появилась, юзер-тип для описания ошибки и т.д.).

Применить этот класс в нашем случае можно следующим образом: boost::leaf::result>. При этом, по описанным выше причинам, эта конструкция имеет намного больше смысла, чем optional>, описанная до этого.

__declspec(dllexport) boost::leaf::result> make_person(const char* name) noexcept
try
{
  return std::make_unique(std::move(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 создавался только через фабричную функцию, чтобы гарантировать, что:

  1. Этот объект вернётся по указателю.

  2. deleter, используемый unique_ptr, будет точно инстанциирован в том же модуле, в котором объект был инициализирован.

Но сейчас пользователь может сконструировать Person из другого модуля, поскольку у этого класса есть публичный конструктор. Понятно, что можно обговорить на словах контракт, что так делать не стоит, но мы можем просто запретить так делать, пометив коструктор приватным и заfriend`див фабричную функцию.

При этом нам нужно учесть, что, согласно правилу пяти / трёх (см. «Правила Трех, Пяти и Ноля» от @MaxRokatansky), при соблюдении некоторых условий, компилятор может автоматически генерировать следующий код:

  1. Конструкторы копирования и перемещения.

  2. Копирующий и перемещающий операторы присвоения.

  3. Конструктор по умолчанию.

Я рекоммендую в любой ситуации, когда вам не нужны эти автогенерируемые члены, явно их удалять, а если вы подумали, что они вам нужны, то в 80% случев лучше передумать. Несчётно то количество ошибок, которые были и будут порождены случайным копированием или перемещением. Последствия могут быть так же печальны, если класс не был спроектирован так, чтобы учитывать конструирование через сгенерированный конструктор «по умолчанию».

В нашей ситуации, чтобы избежать генерации всех этих автогенерируемых членов класса, нам достаточно cделать класс «некопируемым», пометив коструктор копирования и оператор копирования = delete.

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> make_person(const char* name) noexcept;
  
  /* some impl */
};

__declspec(dllexport) boost::leaf::result> make_person(const char* name) noexcept
try
{
  return std::make_unique(std::move(name));
}
catch(const std::exception& e)
{
  return boost::leaf::new_error(e.what());
}
catch(...) 
{
  return boost::leaf::new_error("unexpected exception caught");
}

Заключение

На самом деле наилучший совет для перехода бинарной границы модулей с разным ABI — не переходить её. Чаще всего игра не стоит свеч (и в очередной раз отстреленных ног), поэтому проще использовать header-only версию библиотеки, если она есть. Или же пересобрать библиотеку под свой тулсет, или найти уже пересобранную.

Спасибо вам, за уделённое время, надеюсь данная статья была для вас полезна. Если у вас возникли вопросы, вы заметили ошибку или у вас есть предложение по улучшению статьи, то я буду рад любой обратной связи в ЛС.

Подписывайтесь, чтобы не пропустить уведомление о новых статьях (и, конечно же, чтобы порадовать меня). Рекоммендую так же ознакомиться с моей предыдущей статьей: Продление жизни временных значений в С++: рецепты и подводные камни.

Избегайте остроумия и HolyHandGrenade, всем KISS.

Чем больше я знаю про С++, тем меньше я знаю

Чем больше я знаю про С++, тем меньше я знаю

© Habrahabr.ru