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

Вопрос ABI (Application Binary Interface), бинарной границы и бинарной совместимости в C++, раскрыт на просторах интернета не так хорошо как хотелось бы. Особенно сложно в его изучении приходится новичкам, потому что эта тема связана со множеством деталей нарочно скрытой от глаз программиста имплементации языка.

Приправив всё авторскими (и не только) мемами и юморесками за 300, я пострался привнести больше ясности об этой теме в этой статье.

Подробнее о статье

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

В первой части статьи я поделюсь теоретическими знаниями о том, что такое 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. и так далее.


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

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

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

  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{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{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 при переходе бинарной границы возможен, если (список приближённый и всё может варьироваться в зависимости от конкретной имплементации компилятора):

  1. Для компиляции модулей используется один и того же компилятор.

  2. И окружение при компиляции модулей одно и то же.

  3. И вы используете одни и те же параметры компиляции.

  4. И разработчики компилятора поддержали такую возможность.

Поэтому, с некоторой вероятностью, если вы решите поэкспереминтировать самостоятельно, то вы можете увидеть работающий 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 (иначе мы не сможем вернуть его в другой модуль):

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

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

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

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

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

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

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

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

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

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

    1. Выделение памяти
      T* p = static_cast<T*>(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<T>. Насколько вы видите, это тоже шаблон класса.

Одна из состовляющих, обеспечивающих механизм шаблонов в 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<void> для функций, которые ничего не возвращают.

  3. В 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 создавался только через фабричную функцию, чтобы гарантировать, что:

  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<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 — не переходить её. Чаще всего игра не стоит свеч (и в очередной раз отстреленных ног), поэтому проще использовать header-only версию библиотеки, если она есть. Или же пересобрать библиотеку под свой тулсет, или найти уже пересобранную.

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

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

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

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

UPD:

15.10.23:

  1. Добавил разьяснение про то, почему при переходе бинарной границы не стоит рассчитывать на copy elision. Ссылка на уточнение тут. Спасибо @KanuTaH, что обратил внимание на эту неточность!

Комментарии (22)


  1. Kelbon
    13.10.2023 12:36

    Я не написал return std::move(person);

    и не надо его там писать. Никакого "барьера оптимизации" в случае каких-то бы то ни было линковок не происходит. А вот std::move(person) как раз создаст барьер оптимизации

    https://godbolt.org/z/T9jTE965c

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

    Кажется это противоречит совету использовать unique_ptr

    P.S. так как это NRVO и в статье есть ещё return, то можно просто на месте создать person для гарантированной оптимизации

    P.P.S в статье раз 7 фигурирует std::move на const char*, видимо опечатка


    1. reficul0 Автор
      13.10.2023 12:36

      Почему не происходит?


      1. Kelbon
        13.10.2023 12:36

        Потому что адрес возврата у функции будет откуда бы её ни вызвали


    1. reficul0 Автор
      13.10.2023 12:36

      Никакого "барьера оптимизации"

      Можете, пожалуйста, пояснить более подробно?
      Я исхожу из того, что вызов функции make_person происходит из другого программного модуля, из-за чего copy elision - невозможно.

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


      1. Kelbon
        13.10.2023 12:36

        Но сама функция осуществляет возврат, это бы значило, что она должна иметь 2 версии - одну на случай когда её зовут из длл, другую на случай если зовут не из длл. Очевидно этого не должно быть, функция одна


        1. reficul0 Автор
          13.10.2023 12:36

          Пожалуйста, ознакомьтесь с https://stackoverflow.com/questions/23777863/do-rvo-and-copy-elision-only-work-within-one-compilation-unit-or-not.
          RVO возможна только в одной единице трансляции (если брать в расчёт специальные опции линковщика). Поэтому, в общем случае, RVO и NRVO при пересечении бинарной границы между разными модулями - невозможны.


          1. KanuTaH
            13.10.2023 12:36

            Пожалуйста, ознакомьтесь с https://stackoverflow.com/questions/23777863/do-rvo-and-copy-elision-only-work-within-one-compilation-unit-or-not.

            Простите, а когда вы черпаете вот эти свои знания из советских газет наподобие StackOverflow, вы вообще проверяете то, что там пишут, или нет? А то там пишут очень много чуши.

            Несложная проверка
            gentoo ~/Temp/dlltest $ cat lib.h
            #pragma once
            
            struct S
            {
                S();
                S(const S&);
                S(S&&);
            
                int i = 0;
            };
            
            S foo1();
            S foo2();
            
            int bar();
            
            gentoo ~/Temp/dlltest $ cat lib.cpp
            #include "lib.h"
            
            #include <iostream>
            
            int bar()
            {
                S s1 = foo1();
            
                std::cout << "----------" << std::endl;
            
                S s2 = foo2();
            
                return s1.i + s2.i;
            }
            
            gentoo ~/Temp/dlltest $ cat main.cpp
            #include "lib.h"
            
            #include <iostream>
            #include <utility>
            
            S::S()
            {
                std::cout << "S()" << std::endl;
            }
            
            S::S(const S&)
            {
                std::cout << "S(const S&)" << std::endl;
            }
            
            S::S(S&&)
            {
                std::cout << "S(S&&)" << std::endl;
            }
            
            S foo1()
            {
                std::cout << "foo1()" << std::endl;
            
                S s;
                return s;
            }
            
            S foo2()
            {
                std::cout << "foo2()" << std::endl;
            
                S s;
                return std::move(s);
            }
            
            int main()
            {
                return bar();
            }
            
            # Не применяем -flto, плюс на всякий случай делаем динамическую библиотеку
            gentoo ~/Temp/dlltest $ g++ -Wall -fPIC -shared -o lib.so lib.cpp
            # После этой строчки никакие дополнительные оптимизации в lib.so не выполняются
            # "Код библиотеки фиксирован и полностью собран" (C)
            
            # Не применяем -flto
            gentoo ~/Temp/dlltest $ g++ -Wall -c -o main.o main.cpp
            main.cpp: In function ‘S foo2()’:
            main.cpp:34:21: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
               34 |     return std::move(s);
                  |            ~~~~~~~~~^~~
            main.cpp:34:21: note: remove ‘std::move’ call
            
            # Не применяем -flto
            gentoo ~/Temp/dlltest $ g++ -o prog main.o lib.so
            
            # Проверяем, что динамическая библиотека реально используется
            gentoo ~/Temp/dlltest $ ./prog
            ./prog: error while loading shared libraries: lib.so: cannot open shared object file: No such file or directory
            
            gentoo ~/Temp/dlltest $ LD_LIBRARY_PATH=. ./prog
            foo1()
            S() <- RVO вполне себе сработало внутри .so
            ----------
            foo2()
            S()
            S(S&&) <- RVO не сработало, так как мы явно попросили об этом через std::move()
            


            1. reficul0 Автор
              13.10.2023 12:36

              Прошу, пожалуйста, обратить внимание:

              Поэтому, в общем случае, RVO и NRVO при пересечении бинарной границы между разными модулями - невозможны.


              1. KanuTaH
                13.10.2023 12:36

                Простите, но это всего лишь ваше утверждение, ни на чем не основанное. Ну кроме фантазий со Stack Overflow.


                1. reficul0 Автор
                  13.10.2023 12:36

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

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


                  1. KanuTaH
                    13.10.2023 12:36

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


                    1. reficul0 Автор
                      13.10.2023 12:36
                      -1

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

                      При минимальном рассмотрении теория о том, что copy elision будет поддреживается в общем случае, представяется несостоятельной. Мои рассуждения, в результате которых я делаю такой вывод, следующие:

                      Во-первых,

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

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

                      В частности,

                      Вызывающий функцию модуль, как и модуль, содержащий реализацию функции, всегда может быть собран со стандартом меньшим, чем С++17, например С++11/C++14. Поэтому компилятор, при сборке этого модуля, согласно стандарту С++11 и C++14, не обязан поддерживать copy elision.

                      Мой вывод:

                      Это implementation defined поведение, на которое я бы не стал завязываться, если вас интересует вопрос совместимости двух, потенциально собранных разными инструментами, модулей.

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


                      1. Kelbon
                        13.10.2023 12:36
                        -1

                        Щас бы чатгпт использовать для ответа


                      1. reficul0 Автор
                        13.10.2023 12:36

                        Щас бы чатгпт использовать для ответа

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


                  1. Kelbon
                    13.10.2023 12:36

                    Сначала объясните как вообще возможно то о чём вы говорите. Функция не знает как её вызовут


              1. Kelbon
                13.10.2023 12:36

                Да нет никакой границы между модулями. Не су ще ству ет её. Только на винде из-за кривого динамического линкера. В остальных случаях - нет никакой границы, её невозможно пронаблюдать


    1. reficul0 Автор
      13.10.2023 12:36

      Кажется это противоречит совету использовать unique_ptr

      Чтобы прояснить ситуацию с умными указателями, пожалуйста, ознакомьтесь с пунктом 2.2 Конструирование объекта


    1. reficul0 Автор
      13.10.2023 12:36

      P.P.S в статье раз 7 фигурирует std::move на const char*, видимо опечатка

      Спасибо! Исправил.


  1. SpiderEkb
    13.10.2023 12:36
    +1

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

    Экзотика-не экзотика, но вот на платформе, на которой работаю последние 6 лет это стандартный (на уровне системы) подход.

    Есть понятие т.н. "структурированной ошибки"

     typedef struct Qus_EC
        {
           int  Bytes_Provided;
           int  Bytes_Available;
           char Exception_Id[7];
           char Reserved;
           char Exception_Data[];           /* Varying length        */
        } Qus_EC_t;

    суть которой в 7-значном коде ошибки + опциональном наборе данных (если таковые требуются). Т.е. условно говоря:

    char error[sizeof(Qus_EC) + 50];
    Qus_EC* pError = (Qus_EC*)error;
    
    pError->Bytes_Provided = sizeof(error);
    pError>Bytes_Available = 0;

    Далее передаем pError куда нужно и на выходе проверяем

    if (pError>Bytes_Available > 0); // где-то там была ошибка и структура заполнена

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

    Например, код сообщения CPFAA01, текст "Attribute &1 for resource &2 type &3 not allowed to be monitored". Вместо &1, &2, &3 будут подставлены данные из блока Exception_Data (формат этого блока также хранится в message файле в виде количества возможных параметров и размера каждого из них).

    Естественно, мы можем сам добавлять нужные нам сообщения в message файлы.

    И, естественно, есть системная API, позволяющая по структуре Qus_EC получать полный текст ошибки.

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

    Справедливо только для исключений языка. А вот если механизм исключений поддерживается на уровне системы... то все становится намного проще. У каждого процесса (задания) в системе есть своя очередь сообщений. Куда можно помещать те же самые сообщения, что описаны выше. И этот механизм уже начинает работать не как возвращаемая ошибка, а как исключение. Которое на боится API т.к. идет в обход него. Помещение сообщения в очередь аналогично throw(), ну а catch уже сами размещаем где нам надо это исключение поймать. Хоть в этой программе, хоть в вызывающей ее... Например, можем сделать так:

      /* Interrupt handler parameter block */
      typedef _Packed struct {
        unsigned int    Block_Size;       /* Size of the parameter block   */
        _INVFLAGS_T     Tgt_Flags;        /* Target invocation flags       */
        char            reserved[8];      /* reserved                      */
        _INVPTR         Target;           /* Current target invocation     */
        _INVPTR         Source;           /* Source invocation             */
        _SPCPTR         Com_Area;         /* Communications area           */
        char            Compare_Data[32]; /* Compare Data                  */
        char            Msg_Id[7];        /* Message ID                    */
        char            reserved1;        /* 1 byte pad                    */
        _INTRPT_Mask_T  Mask;             /* Interrupt class mask          */
        unsigned int    Msg_Ref_Key;      /* Message reference key         */
        unsigned short  Exception_Id;     /* Exception ID                  */
        unsigned short  Compare_Data_Len; /* Length of Compare Data        */
        char            Signal_Class;     /* Internal signal class         */
        char            Priority;         /* Handler priority              */
        short           Severity;         /* Message severity              */
        char            reserved3[4];     /* reserved                      */
        int             Msg_Data_Len;     /* Len of available message data */
        char            Mch_Dep_Data[10]; /* Machine dependent date        */
        char            Tgt_Inv_Type;     /*Invocation type (in MIMCHOBS.H)*/
        _SUSPENDPTR     Tgt_Suspend;      /* Suspend pointer of target     */
        char            Ex_Data[48];      /* First 48 bytes of excp. data  */
      } _INTRPT_Hndlr_Parms_T;
    
    static void ExeptHandler(_INTRPT_Hndlr_Parms_T* pexcp_data)
    {
      // в pexcp_data вся информация об исключении
      // обрабатываем по своему усмотрению
    }
    
    // где знаем что может возникнуть исключение ставим
    #pragma exception_handler(ExeptHandler, 0, _C1_ALL, _C2_ALL, _CTLA_HANDLE_NO_MSG)

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

    monitor;
      // тут код, который может вызвать исключение
    on-excp 'CPF501B';
      // если вылетело исключение с кодом CPF501B - попадем сюда
    on-error;
      // если возникло другое исключение попадем сюда
    end-mon;

    Это уже полный аналог try-catch. Но в обход ABI независимо от языка на котором написано то, что вызвало исключение. Для извлечения сообщения об исключении из очереди также есть системная API.

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


    1. reficul0 Автор
      13.10.2023 12:36

      Очень интересный подход, спасибо, что поделились! И правда, если решать обработку ошибок на уровне системы, то проблем убудет. Это случайно не ОС под встроенную систему? От такого подхода с отказом от исключений на уровне системы немного веет программированием под встроенные системы.

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

      Вцелом, я думаю, что такие подходы с переписыванием стандартных механизмов - очень дорогие, в связи с чем мало кто может себе такое позволить. И рассматривание таких механизмов - очень узкоспециализированная деятельность, далеко выходящая за рамки моего overview.


      1. SpiderEkb
        13.10.2023 12:36
        +1

        Это случайно не ОС под встроенную систему?

        Даже близко не лежало :-) Это IBM i (IBM System i) "в девичестве" AS/400 (между собой ее так и называют - АС-ка).

        Система нишевая - высоконадежные, высокопроизводительные коммерческие сервера на процессорах PowerS (у нас сейчас версия 7.4TR5 на процессорах Power9, самая свежая - 7.5 (не в курсе какой там последний TR для нее есть), самые свежие процессоры - Power10.

        Это не мейнфреймы (которые у IBM тоже есть - IBM z и система AIX), они позиционируют платформу как middleware.

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

        Сообщения (исключения) могут быть разных уровней -

        *CMD Command

        *COMP Completion

        *DIAG Diagnostic

        *ESCAPE Escape

        *INFO Informational

        *INQ Inquiry

        *NOTIFY Notify

        *RQS Request

        *STATUS Status

        Т.е. в целом это куда больше чем механизм исключений.

        Если интересно, есть RedBook RPG: Exeption and Error Handling (RPG - специализированный язык для работы с БД и коммерческой логики, более 80% кода на этой платформе на нем пишется).

        Вообще, это полностью "укомплектованная" самодостаточная система - покупая сервер вы сразу получаете все - установленную операционку с интегрированной (фактически это часть ОС) БД (DB2 for i), интегрированные в систему компиляторы языков - CL (Command Language, язык системных команд которые можно как в интерактиве для работы с системой, так и писать компилируемые программы на нем, можно свои команды создавать), COBOL (не знаю, пишет кто на нем сейчас или нет, но он есть), RPG (наиболее используемый язык тут), С/С++ (не сильно свежие, если не сказать сильно несвежие - вроде ка С++11 там сейчас)...

        Еще одна замечательная вещь тут - концепция интегрированной языковой среды (ILE) - это уже вот прямо про ABI. Суть ее в том, что вы можете написать исходник на С/С++, исходник на RPG, исходник на CL, исходник на COBOL, потом каждый из них соотв. командой скомпилировать с "модуль" (объект типа *MODULE - функционально аналог объектного файла .obj в винде или .o в линуксе), а потом эти модули собрать (bind) в одну программу ("программный объект" - *PGM). И, если вы правильно опишите прототипы функций, то сможете вызывать из кода на RPG то, что написано, например, на C/С++.

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

        Например, есть функция сишная:

          struct timeval {
             long  tv_sec;                  /* second                        */
             long  tv_usec;                 /* microseconds                  */
           };
        
          struct timezone {
              int  tz_minuteswest;          /* minutes west of Greenwich     */
              int  tz_dsttime;              /* daylight savings time flag    */
           };
        
          int gettimeofday(struct timeval *, struct timezone *);

        В RPG такого вот прямо нет, а вдруг потребовалось. Пишем:

              //==============================================================================
              // Текущее время в секундах с точностью до мкс
              //==============================================================================
              dcl-proc GetTime;
                dcl-pi *n packed(16: 6);
                end-pi;
        
                dcl-ds t_dsTimeVal qualified template align(*full);
                  tv_sec             int(10) inz;
                  tv_usec            int(10) inz;
                end-ds;
        
                dcl-ds t_dsTimeZone qualified template align(*full);
                  tz_minuteswest     int(10) inz;
                  tz_dsttime         int(10) inz;
                end-ds;
        
                dcl-pr GetTimeofDay int(10) extproc(*CWIDEN : 'gettimeofday');
                  dsTime             likeds(t_dsTimeVal);
                  dsZone             likeds(t_dsTimeZone) options(*omit);
                end-pr;
        
                dcl-ds dsTimeVal     likeds(t_dsTimeVal) inz(*likeds);
                dcl-s  pktTime       packed(16: 6) inz(*zero);
                dcl-s  fltSecs       float(8);
                dcl-s  fltUSecs      float(8);
        
                if GetTimeofDay(dsTimeVal: *omit) = 0;
                  fltSecs  = dsTimeVal.tv_sec;
                  fltUSecs = dsTimeVal.tv_usec;
        
                  fltUSecs /= 1000000;
                  pktTime = fltSecs + fltUSecs;
                endif;
        
                return pktTime;
              end-proc;

        Для понимания -

        • packed(16: 6) - тип данных с фиксированной точкой, 16 знаков, 6 после запятой (кто знает SQL - 100% соответствует DECIMAL(16,6), в RPG вообще есть все типы данных которые есть в БД и SQL частности).

        • int(10) - соответствует int32 (для int16 будет int(5), int64 - int(20)

        • float(8) - double

        • dcl-pr GetTimeofDay int(10) extproc(*CWIDEN : 'gettimeofday'); - это как раз прототип функции со ссылкой на экспортируемую кем-то функцию gettimeofday

        • options(*omit) в описании параметра - значит что вместо него можно передать спецзначение *omit (по умолчанию параметры в RPG передаются по ссылке т.е. dsTime likeds(t_dsTimeVal) в RPG прототипе полностью соответствует struct timeval * в сишном прототипе. Передача *omit вместо параметра равнозначна передаче NULL. Еще может быть *nopass для одного или нескольких последних параметров, но С такого не поддерживает напрямую, хотя в RPG используется часто - есть даже проверка %passed(parmName) - передали этот параметр или нет. Для *omit, кстати, проверка %omited(parmName)

        Т.о. вызов

        GetTimeofDay(dsTimeVal: *omit);

        фактически означает

        gettimeofday(&dsTimeVal, NULL);

        Правда, такой подход не очень часто используем. Ну разве что в программе на RPG нужен какой-то кусок (функция) которую проще и удобнее на С написать. А так, обычно, С/С++ код оформлется в отдельную сервисную программу (*SRVPGM - аналог dll в винде).

        Вот, к примеру, есть у нас такой тип объекта - User Queue (*USRQ - очередь куда можно писать/читать произвольные данные, в т.ч. и с ключем). Очень полезный в хозяйстве объект, но работать с ним очень муторно, особенно из RPG (там вся работа через машинные инструкции - MI, в сишной библиотеке для них есть врапперы).

        Пишем удобный для работы API на С/С++, собираем все это в сервисную программу. Пишем заголовочный файл с прототипами на RPG и пользуемся хоть из Сишных программ, хоть из RPGшных.

        Например, в Сишном хидере

        extern "C" int USRQ_Send(int hQueue, char* __ptr128 pBuffer, int nBuffLen, 
                                 char* __ptr128 pError);
        extern "C" int USRQ_SendKey(int hQueue, char* __ptr128 pBuffer, int nBuffLen, 
                                    char* __ptr128 pKey, int nKeyLen, char* __ptr128 pError);
        

        __ptr128 - небольшая специфика. Дело в том, что сервисная программа по ряду необходимостей использует модель памяти TERASPACE с 64бит указателем, которая позволяет выделять до 2Гб памяти одним куском, а RPGшные программы работают только в модели памяти SINGLE LEVEL - там указатели 128бит (в нем кроме указателя еще много всякой информации типа тегов защиты и т.п.), но одним куском выделить можно не более 16Мб. Так что здесь указывается что передаваемый указатель не 64, а 128бит.

        В RPGшном оно же

        // Отправка сообщения в queLIFO/queFIFO очередь
        // Возвращает количество отправленных байт
        // в случае ошибки -1
        dcl-pr USRQ_SendMsg int(10) overload(USRQ_Send: USRQ_SendKey);
        
        dcl-pr USRQ_Send int(10) extproc(*CWIDEN : 'USRQ_Send') ;
          hQueue    int(10)                    value;                                  // handle объекта (возвращается USRQ_Connect)
          pBuffer   char(64000)                options(*varsize);                      // Буфер для отправки
          nBuffLen  int(10)                    value;                                  // Количество байт для отправки
          Error     char(37)                   options(*omit);                         // Ошибка
        end-pr;
        
        // Отправка сообщения в queKeyd очередь
        // Возвращает количество отправленных байт
        // в случае ошибки -1
        
        dcl-pr USRQ_SendKey int(10) extproc(*CWIDEN : 'USRQ_SendKey') ;
          hQueue    int(10)                    value;                                  // handle объекта (возвращается USRQ_Connect)
          pBuffer   char(64000)                options(*varsize);                      // Буфер для отправки
          nBuffLen  int(10)                    value;                                  // Количество байт для отправки
          pKey      char(256)                  const;                                  // Значение ключа сообщения
          nKeyLen   int(10)                    value;                                  // Фактический размер ключа
          Error     char(37)                   options(*omit);                         // Ошибка
        end-pr;
        • value - параметр передается не по ссылке, а по значению

        • options(*varsize) - значит что реальный размер передаваемого параметра может быть любым, не обязательно 64000 байт (реальный размер передается отдельно). Система позволяет добавить в описание функции модификатор opdesc (а на стороне С добавляется специальная pragma) и тогда параметры будет передаваться в сочетании со специальными "операционными дескрипторами" и для каждого параметра специальной системной апишкой можно получить его тип и реальный размер (все есть объект - даже переменная в программе и система хранит свойства этого объекта и может передать их в вызываемую функцию в операционном дескрипторе). Но это несколько снижает производительность, а для нас производительность больное место, так что без крайней нужды стараемся не использовать такое.

        • Error char(37) - это та самая "структурированная ошибка". Правда, в RPG мы используем "усеченный вариант" - 7 символов код ошибки + 3 параметра по 10 символов. А особенность RPG в том, что структура (ds) тут трактуется как строка. Т.е. мы можем передавать как строку 37 символов, так и структуру из 4-х полей - char(7) для кода + 3 по char(10) для параметров.

        • dcl-pr USRQ_SendMsg int(10) overload(USRQ_Send: USRQ_SendKey); - да, RPG поддерживает overload для функций, возвращающих одинаковый тип данных, но с разным количеством и/или типом параметров. В коде пишем USRQ_SendMsg, а компилятор уже по набору параметров сам разбирается что реально подставить в вызов - USRQ_Send или USRQ_SendKey

        Вот как-то так...

        За 6 лет уже прикипел к этой системе настолько что уходить в нее никуда не хочется. Столько ту возможностей и настолько она внутренне цельная...

        Вообще, есть "библия" - Френк Солтис. "Основы AS/400". Один из отцов-основателей этой системы.

        Цитата от автора:

        Менее года назад я был в Буэнос-Айресе на встрече с группой пользователей этой системы. По окончании встречи молодой репортер газеты «La Nacion» спросил меня: «Сформулируйте, пожалуйста, коротко причины того, почему в AS/400 столь много новшеств?». И я ответил: «Потому что никто из ее создателей не заканчивал MIT.»

        Заинтригованный моим ответом, репортер попросил разъяснений. Я сказал, что не собирался критиковать MIT, а лишь имел в виду то, что разработчики AS/400 имели совершенно иной опыт, нежели выпускники MIT. Так как всегда было трудно заставить кого-либо переехать с восточного побережья в 70-тысячный миннесотский городок, в лаборатории IBM в Рочестере практически не оказалось выпускников университетов, расположенных на востоке США. И создатели AS/400 — представители «школ» Среднего Запада — не были так сильно привязаны к проектным решениям, используемым другими компаниями.

        Т.е. создатели шли по своему пути и делали все без оглядки на то, как это реализовано у других. Согласитесь - работает вы в Windows или в Linux - внешне они похожи. Файлы, папки... Что в Windows, что в Linux у файла есть имя, есть расширение... И там и там вы можете открыть исполняемый файл HEX редактором и поправить там пару-тройку байтиков.

        В АС-ке все не так. Есть объекты, у объекта есть имя (можно менять) каждый объект имеет свой тип и набор характерных для типа атрибутов (например, есть объект типа *FILE - это может быть "физический файл исходных текстов" - pf-src или "физический фал данных" - таблица БД - pf-dta или "логический файл" - некий аналог индекса, но в более широком смысле - lf). Вы не можете открыть редактором исполняемую программу - для объекта типа *PGM такая операция не определена в системе и следовательно невозможна.

        Здесь нет папок, есть "библиотеки" (тоже объект - типа *LIB). Вместо переменной path тут есть Library List (*LIBL) - список библиотек где будет искаться запрашиваемый объект если явно не указана его библиотека. И вы в любой момент может добавить в либл библиотеку (ADDLIBLE) или удалить ее из либла (RMVLIBLE).

        Ладно, понесло меня :-) Тут бесконечно можно рассказывать... Система очень богатая возможностями и реально необычная и интересная.


        1. reficul0 Автор
          13.10.2023 12:36

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